From a1de70fdb61abd4b0f56c2b3ed9d43aafb302dc8 Mon Sep 17 00:00:00 2001 From: "vysakh.menon" Date: Thu, 18 Jan 2024 13:19:26 -0800 Subject: [PATCH 1/3] 18452 Merge branch 'main' into feature-legal-name --- .github/workflows/business-auth-ci.yml | 4 + .../business-filings-notebook-report-cd.yml | 2 +- .../business-sftp-icbc-report-cd.yml | 2 +- .../entity-digital-credentials-cd.yml | 114 + .../entity-digital-credentials-ci.yml | 110 + colin-api/devops/vaults.json | 4 +- colin-api/flags.json | 3 + colin-api/requirements.txt | 26 +- colin-api/requirements/dev.txt | 6 +- colin-api/src/colin_api/__init__.py | 19 +- colin-api/src/colin_api/config.py | 20 + colin-api/src/colin_api/errorhandlers.py | 68 + colin-api/src/colin_api/models/business.py | 60 +- colin-api/src/colin_api/resources/__init__.py | 2 +- colin-api/src/colin_api/resources/business.py | 5 + colin-api/src/colin_api/resources/event.py | 3 + colin-api/src/colin_api/resources/filing.py | 5 +- colin-api/src/colin_api/resources/office.py | 2 + colin-api/src/colin_api/resources/parties.py | 3 + .../colin_api/resources/program_account.py | 2 + colin-api/src/colin_api/resources/reset.py | 2 + .../src/colin_api/resources/share_struct.py | 2 + colin-api/src/colin_api/utils/auth.py | 20 + colin-api/src/colin_api/utils/util.py | 1 - colin-api/src/colin_api/version.py | 2 +- .../postman/colin-api.postman_collection.json | 130 + jobs/update-colin-filings/Makefile | 4 +- .../update_colin_filings.py | 45 +- jobs/update-legal-filings/setup.cfg | 2 +- .../update_legal_filings.py | 62 +- legal-api/Makefile | 4 +- legal-api/gunicorn_config.py | 4 + legal-api/gunicorn_server.py | 10 + .../migrations/versions/5238dd8fb805_.py | 73 + legal-api/pyproject.toml | 1 + .../amalgamationApplication.html | 13 + .../certificateOfAmalgamation.html | 14 + .../certificateOfNameChange.html | 3 + legal-api/report-templates/correction.html | 6 +- .../letterOfAgmExtension.html | 65 + .../letterOfAgmLocationChange.html | 43 + ...pecialResolutionCorrectionApplication.html | 2 +- .../common/businessDetails.html | 2 - .../correction/associateType.html | 18 + .../template-parts/correction/resolution.html | 7 + .../correction/rulesMemorandum.html | 23 + .../registration/completingParty.html | 10 +- .../resolutionApplicationCorrection.html | 72 - legal-api/requirements.txt.1 | 52 - .../requirements/bcregistry-libraries.txt | 2 +- legal-api/src/legal_api/config.py | 16 + legal-api/src/legal_api/core/filing.py | 24 + legal-api/src/legal_api/core/filing_helper.py | 44 +- legal-api/src/legal_api/core/meta/filing.py | 111 +- legal-api/src/legal_api/decorators.py | 78 + legal-api/src/legal_api/models/__init__.py | 8 + .../legal_api/models/amalgamating_business.py | 55 + .../src/legal_api/models/amalgamation.py | 76 + .../src/legal_api/models/dc_connection.py | 18 + .../src/legal_api/models/dc_definition.py | 13 +- .../dc_issued_business_user_credential.py | 57 + .../legal_api/models/dc_issued_credential.py | 25 +- .../legal_api/models/dc_revocation_reason.py | 29 + legal-api/src/legal_api/models/filing.py | 63 +- .../src/legal_api/models/legal_entity.py | 10 +- legal-api/src/legal_api/reports/report.py | 194 +- .../resources/v2/business/business.py | 17 +- .../business/business_digital_credentials.py | 220 +- .../v2/business/business_directors.py | 2 - .../business_filings/business_filings.py | 58 +- .../src/legal_api/resources/v2/namerequest.py | 28 +- legal-api/src/legal_api/services/authz.py | 524 ++-- .../legal_api/services/digital_credentials.py | 380 ++- .../filings/validations/agm_extension.py | 164 ++ .../validations/agm_location_change.py | 47 + .../filings/validations/alteration.py | 2 +- .../validations/amalgamation_application.py | 227 ++ .../filings/validations/correction.py | 41 +- .../filings/validations/validation.py | 14 +- .../src/legal_api/services/pdf_service.py | 42 +- legal-api/src/legal_api/services/utils.py | 14 + .../business/business_checks/firms.py | 13 +- legal-api/src/legal_api/utils/formatting.py | 24 + .../legal_api/utils/legislation_datetime.py | 5 + legal-api/src/legal_api/version.py | 2 +- .../tests/unit/core/test_filing_ledger.py | 13 +- legal-api/tests/unit/models/__init__.py | 9 +- .../unit/models/test_amalgamating_business.py | 85 + .../tests/unit/models/test_amalgamation.py | 83 + .../tests/unit/models/test_dc_definition.py | 12 +- .../tests/unit/models/test_legal_entity.py | 67 +- .../tests/unit/resources/v2/test_business.py | 29 +- .../v2/test_business_digital_credentials.py | 202 +- .../resources/v2/test_business_director.py | 31 - .../test_filing_documents.py | 149 ++ .../v2/test_business_filings/test_filings.py | 214 +- .../test_filings_ledger.py | 11 +- .../filings/validations/test_agm_extension.py | 78 + .../validations/test_agm_location_change.py | 66 + .../test_amalgamation_application.py | 1111 ++++++++ .../validations/test_correction_firms.py | 324 +-- .../test_correction_special_resolution.py | 69 + .../filings/validations/test_registration.py | 259 +- .../tests/unit/services/test_authorization.py | 2331 +++++++++-------- .../unit/services/test_digital_credentials.py | 138 +- .../tests/unit/services/test_pdf_service.py | 19 +- .../business/business_checks/test_firms.py | 158 +- legal-api/wsgi.py | 2 +- queue_services/entity-bn/Makefile | 4 +- .../entity_bn/bn_processors/registration.py | 13 +- .../entity-bn/src/entity_bn/config.py | 9 +- .../entity-digital-credentials/.env.sample | 39 + .../entity-digital-credentials/.envrc | 6 + .../entity-digital-credentials/Dockerfile | 37 + .../entity-digital-credentials/LICENSE | 13 + .../entity-digital-credentials/Makefile | 144 + .../entity-digital-credentials/README.md | 46 + .../devops/vaults.json | 33 + .../digital_credentials_service.py | 32 + .../entity-digital-credentials/k8s/Readme.md | 7 + .../k8s/templates/dc.yaml | 175 ++ .../entity-digital-credentials/q_cli.py | 137 + .../requirements.txt | 24 + .../requirements/bcregistry-libraries.txt | 3 + .../requirements/dev.txt | 31 + .../requirements/prod.txt | 16 + .../entity-digital-credentials/setup.cfg | 120 + .../entity-digital-credentials/setup.py | 70 + .../entity_digital_credentials/__init__.py | 17 + .../src/entity_digital_credentials/config.py | 159 ++ .../__init__.py | 17 + .../admin_revoke.py | 32 + .../business_number.py | 33 + .../change_of_registration.py | 34 + .../dissolution.py | 46 + .../put_back_on.py | 33 + .../src/entity_digital_credentials/helpers.py | 143 + .../src/entity_digital_credentials/version.py | 25 + .../src/entity_digital_credentials/worker.py | 163 ++ .../tests/__init__.py | 14 + .../tests/conftest.py | 121 + .../tests/unit/__init__.py | 102 + .../test_admin_revoke.py | 65 + .../test_business_number.py | 67 + .../test_change_of_registration.py | 122 + .../test_dissolution.py | 122 + .../test_put_back_on.py | 66 + .../tests/unit/test_helpers.py | 423 +++ .../tests/unit/test_worker.py | 225 ++ queue_services/entity-emailer/flags.json | 5 + .../src/entity_emailer/config.py | 8 + .../agm_extension_notification.py | 150 ++ .../agm_location_change_notification.py | 150 ++ .../ar_reminder_notification.py | 6 +- .../correction_notification.py | 27 +- .../email_processors/nr_notification.py | 54 +- .../special_resolution_helper.py | 27 +- .../email_templates/AGM-EXT-COMPLETED.html | 56 + .../email_templates/AGM-LOCCHG-COMPLETED.html | 56 + .../email_templates/AR-REMINDER.html | 17 + .../CP-SR-CRCTN-COMPLETED.html | 5 +- .../email_templates/CP-SR-CRCTN-PAID.html | 1 - .../NR-BEFORE-EXPIRY-COLIN.html | 48 + .../NR-BEFORE-EXPIRY-MODERNIZED.html | 50 + .../email_templates/NR-BEFORE-EXPIRY-SO.html | 44 + .../email_templates/NR-BEFORE-EXPIRY.html | 54 +- .../email_templates/NR-EXPIRED.html | 15 +- .../src/entity_emailer/resources/worker.py | 35 +- .../entity-emailer/tests/unit/__init__.py | 173 +- .../test_agm_extension_notification.py | 55 + .../test_agm_location_change_notification.py | 55 + .../test_ar_reminder_notification.py | 29 +- .../test_correction_notification.py | 2 +- .../email_processors/test_nr_notification.py | 1 + .../entity-emailer/tests/unit/test_worker.py | 95 +- 175 files changed, 10703 insertions(+), 2762 deletions(-) create mode 100644 .github/workflows/entity-digital-credentials-cd.yml create mode 100644 .github/workflows/entity-digital-credentials-ci.yml create mode 100644 colin-api/flags.json create mode 100644 colin-api/src/colin_api/errorhandlers.py create mode 100644 colin-api/src/colin_api/utils/auth.py create mode 100644 legal-api/gunicorn_server.py create mode 100644 legal-api/migrations/versions/5238dd8fb805_.py create mode 100644 legal-api/report-templates/amalgamationApplication.html create mode 100644 legal-api/report-templates/certificateOfAmalgamation.html create mode 100644 legal-api/report-templates/letterOfAgmExtension.html create mode 100644 legal-api/report-templates/letterOfAgmLocationChange.html create mode 100644 legal-api/report-templates/template-parts/correction/associateType.html create mode 100644 legal-api/report-templates/template-parts/correction/resolution.html create mode 100644 legal-api/report-templates/template-parts/correction/rulesMemorandum.html delete mode 100644 legal-api/report-templates/template-parts/special-resolution/resolutionApplicationCorrection.html delete mode 100644 legal-api/requirements.txt.1 create mode 100644 legal-api/src/legal_api/decorators.py create mode 100644 legal-api/src/legal_api/models/amalgamating_business.py create mode 100644 legal-api/src/legal_api/models/amalgamation.py create mode 100644 legal-api/src/legal_api/models/dc_issued_business_user_credential.py create mode 100644 legal-api/src/legal_api/models/dc_revocation_reason.py create mode 100644 legal-api/src/legal_api/services/filings/validations/agm_extension.py create mode 100644 legal-api/src/legal_api/services/filings/validations/agm_location_change.py create mode 100644 legal-api/src/legal_api/services/filings/validations/amalgamation_application.py create mode 100644 legal-api/src/legal_api/utils/formatting.py create mode 100644 legal-api/tests/unit/models/test_amalgamating_business.py create mode 100644 legal-api/tests/unit/models/test_amalgamation.py create mode 100644 legal-api/tests/unit/services/filings/validations/test_agm_extension.py create mode 100644 legal-api/tests/unit/services/filings/validations/test_agm_location_change.py create mode 100644 legal-api/tests/unit/services/filings/validations/test_amalgamation_application.py create mode 100644 queue_services/entity-digital-credentials/.env.sample create mode 100644 queue_services/entity-digital-credentials/.envrc create mode 100644 queue_services/entity-digital-credentials/Dockerfile create mode 100644 queue_services/entity-digital-credentials/LICENSE create mode 100644 queue_services/entity-digital-credentials/Makefile create mode 100644 queue_services/entity-digital-credentials/README.md create mode 100644 queue_services/entity-digital-credentials/devops/vaults.json create mode 100644 queue_services/entity-digital-credentials/digital_credentials_service.py create mode 100644 queue_services/entity-digital-credentials/k8s/Readme.md create mode 100644 queue_services/entity-digital-credentials/k8s/templates/dc.yaml create mode 100644 queue_services/entity-digital-credentials/q_cli.py create mode 100644 queue_services/entity-digital-credentials/requirements.txt create mode 100644 queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt create mode 100644 queue_services/entity-digital-credentials/requirements/dev.txt create mode 100644 queue_services/entity-digital-credentials/requirements/prod.txt create mode 100644 queue_services/entity-digital-credentials/setup.cfg create mode 100644 queue_services/entity-digital-credentials/setup.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/__init__.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/__init__.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/admin_revoke.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/version.py create mode 100644 queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py create mode 100644 queue_services/entity-digital-credentials/tests/__init__.py create mode 100644 queue_services/entity-digital-credentials/tests/conftest.py create mode 100644 queue_services/entity-digital-credentials/tests/unit/__init__.py create mode 100644 queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_admin_revoke.py create mode 100644 queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_business_number.py create mode 100644 queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_change_of_registration.py create mode 100644 queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_dissolution.py create mode 100644 queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_put_back_on.py create mode 100644 queue_services/entity-digital-credentials/tests/unit/test_helpers.py create mode 100644 queue_services/entity-digital-credentials/tests/unit/test_worker.py create mode 100644 queue_services/entity-emailer/flags.json create mode 100644 queue_services/entity-emailer/src/entity_emailer/email_processors/agm_extension_notification.py create mode 100644 queue_services/entity-emailer/src/entity_emailer/email_processors/agm_location_change_notification.py create mode 100644 queue_services/entity-emailer/src/entity_emailer/email_templates/AGM-EXT-COMPLETED.html create mode 100644 queue_services/entity-emailer/src/entity_emailer/email_templates/AGM-LOCCHG-COMPLETED.html create mode 100644 queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-COLIN.html create mode 100644 queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-MODERNIZED.html create mode 100644 queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-SO.html create mode 100644 queue_services/entity-emailer/tests/unit/email_processors/test_agm_extension_notification.py create mode 100644 queue_services/entity-emailer/tests/unit/email_processors/test_agm_location_change_notification.py diff --git a/.github/workflows/business-auth-ci.yml b/.github/workflows/business-auth-ci.yml index 17d0be823a..b7073570af 100644 --- a/.github/workflows/business-auth-ci.yml +++ b/.github/workflows/business-auth-ci.yml @@ -72,6 +72,10 @@ jobs: ACCOUNT_SVC_AUTH_URL: https://mock_account_svc_auth_url ACCOUNT_SVC_CLIENT_ID: account_svc_client_id ACCOUNT_SVC_CLIENT_SECRET: account_svc_client_secret + BUSINESS_SCHEMA_ID: test_business_schema_id + BUSINESS_CRED_DEF_ID: test_credential_definition_id + BUSINESS_SCHEMA_NAME: digital_business_card + BUSINESS_SCHEMA_VERSION: "1.0.0" runs-on: ubuntu-20.04 diff --git a/.github/workflows/business-filings-notebook-report-cd.yml b/.github/workflows/business-filings-notebook-report-cd.yml index 2d229d55a1..8141026e0b 100644 --- a/.github/workflows/business-filings-notebook-report-cd.yml +++ b/.github/workflows/business-filings-notebook-report-cd.yml @@ -27,4 +27,4 @@ jobs: working_directory: "./jobs/filings-notebook-report" secrets: WORKLOAD_IDENTIFY_POOLS_PROVIDER: ${{ secrets.WORKLOAD_IDENTIFY_POOLS_PROVIDER }} - GCP_SERVICE_ACCOUNT: ${{ secrets.GCP_SERVICE_ACCOUNT }} \ No newline at end of file + GCP_SERVICE_ACCOUNT: ${{ secrets.GCP_SERVICE_ACCOUNT }} diff --git a/.github/workflows/business-sftp-icbc-report-cd.yml b/.github/workflows/business-sftp-icbc-report-cd.yml index 91589a2087..76a5c09134 100644 --- a/.github/workflows/business-sftp-icbc-report-cd.yml +++ b/.github/workflows/business-sftp-icbc-report-cd.yml @@ -27,4 +27,4 @@ jobs: working_directory: "./jobs/sftp-icbc-report" secrets: WORKLOAD_IDENTIFY_POOLS_PROVIDER: ${{ secrets.WORKLOAD_IDENTIFY_POOLS_PROVIDER }} - GCP_SERVICE_ACCOUNT: ${{ secrets.GCP_SERVICE_ACCOUNT }} \ No newline at end of file + GCP_SERVICE_ACCOUNT: ${{ secrets.GCP_SERVICE_ACCOUNT }} diff --git a/.github/workflows/entity-digital-credentials-cd.yml b/.github/workflows/entity-digital-credentials-cd.yml new file mode 100644 index 0000000000..56ceda2230 --- /dev/null +++ b/.github/workflows/entity-digital-credentials-cd.yml @@ -0,0 +1,114 @@ +name: Entity Digital Credentials CD + +on: + push: + branches: + - main + paths: + - "queue_services/entity-digital-credentials/**" + - "queue_services/common/**" + workflow_dispatch: + inputs: + environment: + description: "Environment (dev/test/prod)" + required: true + default: "dev" + +defaults: + run: + shell: bash + working-directory: ./queue_services/entity-digital-credentials + +env: + APP_NAME: "entity-digital-credentials" + TAG_NAME: "dev" + +jobs: + entity-digital-credentials-cd-by-push: + runs-on: ubuntu-20.04 + + if: github.event_name == 'push' && github.repository == 'bcgov/lear' + environment: + name: "dev" + + steps: + - uses: actions/checkout@v3 + + - name: Login Openshift + shell: bash + run: | + oc login --server=${{secrets.OPENSHIFT4_LOGIN_REGISTRY}} --token=${{secrets.OPENSHIFT4_SA_TOKEN}} + + - name: CD Flow + shell: bash + env: + OPS_REPOSITORY: ${{ secrets.OPS_REPOSITORY }} + OPENSHIFT_DOCKER_REGISTRY: ${{ secrets.OPENSHIFT4_DOCKER_REGISTRY }} + OPENSHIFT_SA_NAME: ${{ secrets.OPENSHIFT4_SA_NAME }} + OPENSHIFT_SA_TOKEN: ${{ secrets.OPENSHIFT4_SA_TOKEN }} + OPENSHIFT_REPOSITORY: ${{ secrets.OPENSHIFT4_REPOSITORY }} + TAG_NAME: ${{ env.TAG_NAME }} + run: | + make cd + + - name: Watch new rollout (trigger by image change in Openshift) + shell: bash + run: | + oc rollout status dc/${{ env.APP_NAME }}-${{ env.TAG_NAME }} -n ${{ secrets.OPENSHIFT4_REPOSITORY }}-${{ env.TAG_NAME }} -w + + - name: Rocket.Chat Notification + uses: RocketChat/Rocket.Chat.GitHub.Action.Notification@master + if: failure() + with: + type: ${{ job.status }} + job_name: "*Entity Digital Credentials Built and Deployed to ${{env.TAG_NAME}}*" + channel: "#registries-bot" + url: ${{ secrets.ROCKETCHAT_WEBHOOK }} + commit: true + token: ${{ secrets.GITHUB_TOKEN }} + + entity-digital-credentials-cd-by-dispatch: + runs-on: ubuntu-20.04 + + if: github.event_name == 'workflow_dispatch' && github.repository == 'bcgov/lear' + environment: + name: "${{ github.event.inputs.environment }}" + + steps: + - uses: actions/checkout@v3 + - name: Set env by input + run: | + echo "TAG_NAME=${{ github.event.inputs.environment }}" >> $GITHUB_ENV + + - name: Login Openshift + shell: bash + run: | + oc login --server=${{secrets.OPENSHIFT4_LOGIN_REGISTRY}} --token=${{secrets.OPENSHIFT4_SA_TOKEN}} + + - name: CD Flow + shell: bash + env: + OPS_REPOSITORY: ${{ secrets.OPS_REPOSITORY }} + OPENSHIFT_DOCKER_REGISTRY: ${{ secrets.OPENSHIFT4_DOCKER_REGISTRY }} + OPENSHIFT_SA_NAME: ${{ secrets.OPENSHIFT4_SA_NAME }} + OPENSHIFT_SA_TOKEN: ${{ secrets.OPENSHIFT4_SA_TOKEN }} + OPENSHIFT_REPOSITORY: ${{ secrets.OPENSHIFT4_REPOSITORY }} + TAG_NAME: ${{ env.TAG_NAME }} + run: | + make cd + + - name: Watch new rollout (trigger by image change in Openshift) + shell: bash + run: | + oc rollout status dc/${{ env.APP_NAME }}-${{ env.TAG_NAME }} -n ${{ secrets.OPENSHIFT4_REPOSITORY }}-${{ env.TAG_NAME }} -w + + - name: Rocket.Chat Notification + uses: RocketChat/Rocket.Chat.GitHub.Action.Notification@master + if: failure() + with: + type: ${{ job.status }} + job_name: "*Entity Digital Credentials Built and Deployed to ${{env.TAG_NAME}}*" + channel: "#registries-bot" + url: ${{ secrets.ROCKETCHAT_WEBHOOK }} + commit: true + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/entity-digital-credentials-ci.yml b/.github/workflows/entity-digital-credentials-ci.yml new file mode 100644 index 0000000000..e79ae304de --- /dev/null +++ b/.github/workflows/entity-digital-credentials-ci.yml @@ -0,0 +1,110 @@ +name: Entity Digital Credentials CI + +on: + pull_request: + types: [assigned, synchronize] + paths: + - "queue_services/entity-digital-credentials/**" + - "queue_services/common/**" + +defaults: + run: + shell: bash + working-directory: ./queue_services/entity-digital-credentials + +jobs: + setup-job: + runs-on: ubuntu-20.04 + + if: github.repository == 'bcgov/lear' + + steps: + - uses: actions/checkout@v3 + - run: "true" + + linting: + needs: setup-job + runs-on: ubuntu-20.04 + + strategy: + matrix: + python-version: [3.8] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + make setup + - name: Lint with pylint + id: pylint + run: | + make pylint + - name: Lint with flake8 + id: flake8 + run: | + make flake8 + + testing: + needs: setup-job + env: + DATABASE_TEST_USERNAME: postgres + DATABASE_TEST_PASSWORD: postgres + DATABASE_TEST_NAME: postgres + DATABASE_TEST_HOST: localhost + NATS_SERVERS: "nats://nats:4222" + NATS_CLIENT_NAME: entity.digital-credentials.tester + NATS_CLUSTER_ID: test-cluster + NATS_ENTITY_EVENT_SUBJECT: entity.events + NATS_QUEUE: entity-digital-credentials-worker + TEST_NATS_DOCKER: True + STAN_CLUSTER_NAME: test-cluster + + runs-on: ubuntu-20.04 + + services: + postgres: + image: postgres:12 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + make setup + - name: Test with pytest + id: test + run: | + make test + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./queue_services/entity-digital-credentials/coverage.xml + flags: entity-digital-credentials + name: codecov-entity-digital-credentials + fail_ci_if_error: true + + build-check: + needs: setup-job + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v3 + - name: build to check strictness + id: build + run: | + make build-nc diff --git a/colin-api/devops/vaults.json b/colin-api/devops/vaults.json index 81d7195e56..ace7440da6 100644 --- a/colin-api/devops/vaults.json +++ b/colin-api/devops/vaults.json @@ -4,7 +4,9 @@ "application": [ "colin-api", "test-oracle", - "sentry" + "sentry", + "jwt", + "launchdarkly" ] } ] diff --git a/colin-api/flags.json b/colin-api/flags.json new file mode 100644 index 0000000000..c3d9f4e151 --- /dev/null +++ b/colin-api/flags.json @@ -0,0 +1,3 @@ +{ + "flagValues": {} +} diff --git a/colin-api/requirements.txt b/colin-api/requirements.txt index 9c99f981bb..6e1e34a59e 100644 --- a/colin-api/requirements.txt +++ b/colin-api/requirements.txt @@ -1,31 +1,35 @@ -Flask-Moment==0.10.0 + +Flask-Moment==0.11.0 Flask-Script==2.0.6 Flask==1.1.2 -Jinja2==2.11.2 +Jinja2==2.11.3 MarkupSafe==1.1.1 Werkzeug==1.0.1 -aniso8601==8.1.0 -attrs==20.3.0 +aniso8601==9.0.1 blinker==1.4 certifi==2020.12.5 -click==7.1.2 +click==8.1.3 cx-Oracle==8.1.0 debugpy ecdsa==0.14.1 -flask-jwt-oidc==0.1.5 +flask-jwt-oidc==0.3.0 flask-restx==0.3.0 -gunicorn==20.0.4 +gunicorn==20.1.0 itsdangerous==1.1.0 -jsonschema==3.2.0 +jsonschema==4.19.0 +launchdarkly-server-sdk==7.1.0 psycopg2-binary==2.8.6 pyasn1==0.4.8 pycountry==20.7.3 pyrsistent==0.17.3 -python-dotenv==0.15.0 +python-dotenv==0.17.1 python-jose==3.2.0 -pytz==2020.4 -rsa==4.6 +pytz==2021.1 +requests==2.25.1 +rsa==4.7.2 +SQLAlchemy==1.4.44 sentry-sdk==1.20.0 six==1.15.0 urllib3==1.26.11 +git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api git+https://github.com/bcgov/business-schemas.git#egg=registry_schemas diff --git a/colin-api/requirements/dev.txt b/colin-api/requirements/dev.txt index a7825c0947..9e3570826d 100644 --- a/colin-api/requirements/dev.txt +++ b/colin-api/requirements/dev.txt @@ -6,7 +6,7 @@ pytest-mock pytest-cov requests pyhamcrest -sqlalchemy +sqlalchemy<=1.4.44 # Lint and code style flake8 @@ -19,7 +19,7 @@ pep8-naming autopep8 coverage pydocstyle<4.0 -pylint<=2.3.1 +pylint pylint-flask isort<5,>=4.2.5 -sqlalchemy +sqlalchemy<=1.4.44 diff --git a/colin-api/src/colin_api/__init__.py b/colin-api/src/colin_api/__init__.py index c79fb1a391..13897dc34a 100644 --- a/colin-api/src/colin_api/__init__.py +++ b/colin-api/src/colin_api/__init__.py @@ -16,23 +16,21 @@ This module is the API for the Legal Entity system. """ import os - import sentry_sdk # noqa: I001; pylint: disable=ungrouped-imports; conflicts with Flake8 -from sentry_sdk.integrations.flask import FlaskIntegration # noqa: I001 + from flask import Flask -from flask_jwt_oidc import JwtManager +from legal_api.services import flags +from sentry_sdk.integrations.flask import FlaskIntegration # noqa: I001 -from colin_api import config -from colin_api.resources import API_BLUEPRINT, OPS_BLUEPRINT +from colin_api import config, errorhandlers +from colin_api.resources import API, API_BLUEPRINT, OPS_BLUEPRINT +from colin_api.utils.auth import jwt from colin_api.utils.logging import setup_logging from colin_api.utils.run_version import get_run_version # noqa: I003; the sentry import creates a bad line count in isort setup_logging(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'logging.conf')) # important to do this first -# lower case name as used by convention in most Flask apps -jwt = JwtManager() # pylint: disable=invalid-name - def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): """Return a configured Flask App using the Factory method.""" @@ -44,9 +42,12 @@ def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): dsn=app.config.get('SENTRY_DSN'), integrations=[FlaskIntegration()] ) + + flags.init_app(app) + errorhandlers.init_app(API) app.register_blueprint(API_BLUEPRINT) app.register_blueprint(OPS_BLUEPRINT) - # setup_jwt_manager(app, jwt) + setup_jwt_manager(app, jwt) @app.after_request def add_version(response): # pylint: disable=unused-variable diff --git a/colin-api/src/colin_api/config.py b/colin-api/src/colin_api/config.py index 76529b260b..57e27d9d18 100644 --- a/colin-api/src/colin_api/config.py +++ b/colin-api/src/colin_api/config.py @@ -64,6 +64,8 @@ class _Config: # pylint: disable=too-few-public-methods SENTRY_DSN = os.getenv('SENTRY_DSN', '') + LD_SDK_KEY = os.getenv('LD_SDK_KEY', None) + # ORACLE - CDEV/CTST/CPRD ORACLE_USER = os.getenv('ORACLE_USER', '') ORACLE_PASSWORD = os.getenv('ORACLE_PASSWORD', '') @@ -72,6 +74,24 @@ class _Config: # pylint: disable=too-few-public-methods ORACLE_PORT = int(os.getenv('ORACLE_PORT', '1521')) ORACLE_BNI_DB_LINK = os.getenv('ORACLE_BNI_DB_LINK', '') + # JWT_OIDC Settings + JWT_OIDC_WELL_KNOWN_CONFIG = os.getenv('JWT_OIDC_WELL_KNOWN_CONFIG') + JWT_OIDC_ALGORITHMS = os.getenv('JWT_OIDC_ALGORITHMS') + JWT_OIDC_JWKS_URI = os.getenv('JWT_OIDC_JWKS_URI') + JWT_OIDC_ISSUER = os.getenv('JWT_OIDC_ISSUER') + JWT_OIDC_AUDIENCE = os.getenv('JWT_OIDC_AUDIENCE') + JWT_OIDC_CLIENT_SECRET = os.getenv('JWT_OIDC_CLIENT_SECRET') + JWT_OIDC_CACHING_ENABLED = os.getenv('JWT_OIDC_CACHING_ENABLED') + JWT_OIDC_USERNAME = os.getenv('JWT_OIDC_USERNAME', 'username') + JWT_OIDC_FIRSTNAME = os.getenv('JWT_OIDC_FIRSTNAME', 'firstname') + JWT_OIDC_LASTNAME = os.getenv('JWT_OIDC_LASTNAME', 'lastname') + try: + JWT_OIDC_JWKS_CACHE_TIMEOUT = int(os.getenv('JWT_OIDC_JWKS_CACHE_TIMEOUT')) + if not JWT_OIDC_JWKS_CACHE_TIMEOUT: + JWT_OIDC_JWKS_CACHE_TIMEOUT = 300 + except (TypeError, ValueError): + JWT_OIDC_JWKS_CACHE_TIMEOUT = 300 + TESTING = False DEBUG = False diff --git a/colin-api/src/colin_api/errorhandlers.py b/colin-api/src/colin_api/errorhandlers.py new file mode 100644 index 0000000000..544228fb19 --- /dev/null +++ b/colin-api/src/colin_api/errorhandlers.py @@ -0,0 +1,68 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Core error handlers and custom exceptions. + +Following best practices from: +http://flask.pocoo.org/docs/1.0/errorhandling/ +http://flask.pocoo.org/docs/1.0/patterns/apierrors/ +""" + +import logging +import sys + +from flask_jwt_oidc import AuthError +from werkzeug.exceptions import HTTPException +from werkzeug.routing import RoutingException + + +logger = logging.getLogger(__name__) + + +def init_app(api): + """Initialize the error handlers for the Flask app instance.""" + api.error_handlers[AuthError] = handle_auth_error + api.error_handlers[HTTPException] = handle_http_error + api.error_handlers[Exception] = handle_uncaught_error + + +def handle_auth_error(error): + """Handle auth errors.""" + error_details = error.error + return error_details, error.status_code + + +def handle_http_error(error): + """Handle HTTPExceptions. + + Include the error description and corresponding status code, known to be + available on the werkzeug HTTPExceptions. + """ + # As werkzeug's routing exceptions also inherit from HTTPException, + # check for those and allow them to return with redirect responses. + if isinstance(error, RoutingException): + return error + + error_details = error.response + return {'message': error.description}, error_details.status_code + + +def handle_uncaught_error(error: Exception): # pylint: disable=unused-argument + """Handle any uncaught exceptions. + + Since the handler suppresses the actual exception, log it explicitly to + ensure it's logged and recorded in Sentry. + """ + logger.error('Uncaught exception', exc_info=sys.exc_info()) + return {'message': 'Internal server errors'}, 500 diff --git a/colin-api/src/colin_api/models/business.py b/colin-api/src/colin_api/models/business.py index a3a6b47e2e..81fcbdbb7c 100644 --- a/colin-api/src/colin_api/models/business.py +++ b/colin-api/src/colin_api/models/business.py @@ -17,9 +17,11 @@ """ from __future__ import annotations +from datetime import datetime from enum import Enum from typing import Dict, List, Optional +from datedelta import datedelta from flask import current_app from colin_api.exceptions import BusinessNotFoundException @@ -87,7 +89,9 @@ class CorpStateTypes(Enum): corp_state = None corp_type = None corp_state_class = None + email = None founding_date = None + good_standing = None jurisdiction = None last_agm_date = None last_ar_date = None @@ -104,7 +108,9 @@ def as_dict(self) -> Dict: 'businessNumber': self.business_number, 'corpState': self.corp_state, 'corpStateClass': self.corp_state_class, + 'email': self.email, 'foundingDate': self.founding_date, + 'goodStanding': self.good_standing, 'identifier': self.corp_num, 'jurisdiction': self.jurisdiction, 'lastAgmDate': self.last_agm_date, @@ -199,16 +205,18 @@ def find_by_identifier(cls, identifier: str, corp_types: List, con=None) -> Busi cursor = con.cursor() cursor.execute( f""" - select corp.corp_num, corp_typ_cd, recognition_dts, bn_15, can_jur_typ_cd, othr_juris_desc, - filing.period_end_dt, last_agm_date, corp_op_state.full_desc as state, - corp_state.state_typ_cd as corp_state, corp_op_state.op_state_typ_cd as corp_state_class + select corp.corp_num, corp.corp_typ_cd, recognition_dts, bn_15, can_jur_typ_cd, othr_juris_desc, + filing.period_end_dt, last_agm_date, corp_op_state.full_desc as state, admin_email, + corp_state.state_typ_cd as corp_state, corp_op_state.op_state_typ_cd as corp_state_class, + corp.last_ar_filed_dt, corp.transition_dt, ct.corp_class from CORPORATION corp join CORP_STATE on CORP_STATE.corp_num = corp.corp_num and CORP_STATE.end_event_id is null join CORP_OP_STATE on CORP_OP_STATE.state_typ_cd = CORP_STATE.state_typ_cd left join JURISDICTION on JURISDICTION.corp_num = corp.corp_num + join corp_type ct on ct.corp_typ_cd = corp.corp_typ_cd join event on corp.corp_num = event.corp_num left join filing on event.event_id = filing.event_id and filing.filing_typ_cd in ('OTANN', 'ANNBC') - where corp_typ_cd in ({stringify_list(corp_types)}) and corp.CORP_NUM=:corp_num + where corp.corp_typ_cd in ({stringify_list(corp_types)}) and corp.corp_num=:corp_num order by filing.period_end_dt desc nulls last """, corp_num=identifier @@ -242,12 +250,14 @@ def find_by_identifier(cls, identifier: str, corp_types: List, con=None) -> Busi ) last_ledger_timestamp = cursor.fetchone()[0] business['last_ledger_timestamp'] = last_ledger_timestamp - # if this is an XPRO, get correct jurisdiction; otherwise, it's BC - if business['corp_typ_cd'] == 'XCP': + # jurisdiction + if business.get('can_jur_typ_cd'): + # This is an XPRO, get correct jurisdiction business['jurisdiction'] = business['can_jur_typ_cd'] if business['can_jur_typ_cd'] == 'OT': business['jurisdiction'] = business['othr_juris_desc'] else: + # This is NOT an XPRO so set to BC business['jurisdiction'] = 'BC' # convert to Business object @@ -258,7 +268,9 @@ def find_by_identifier(cls, identifier: str, corp_types: List, con=None) -> Busi business_obj.corp_state = business['corp_state'] business_obj.corp_state_class = business['corp_state_class'] business_obj.corp_type = business['corp_typ_cd'] + business_obj.email = business['admin_email'] business_obj.founding_date = convert_to_json_datetime(business['recognition_dts']) + business_obj.good_standing = cls.is_in_good_standing(business, cursor) business_obj.jurisdiction = business['jurisdiction'] business_obj.last_agm_date = convert_to_json_date(business['last_agm_date']) business_obj.last_ar_date = convert_to_json_date(business['period_end_dt']) if business['period_end_dt'] \ @@ -380,7 +392,7 @@ def create_resolution(cls, cursor, corp_num: str, event_id: str, resolution_date raise err @classmethod - def get_corp_restriction(cls, cursor, corp_num: str, event_id: str = None) -> Optional[bool, str]: + def get_corp_restriction(cls, cursor, corp_num: str, event_id: str = None): """Get provisions removed flag for this event.""" try: if not event_id: @@ -668,3 +680,37 @@ def reset_corp_states(cls, cursor, event_ids: List): except Exception as err: current_app.logger.error(f'Error in Business: Failed reset ended corp_state rows for events {event_ids}') raise err + + @staticmethod + def is_in_good_standing(business: dict, cursor) -> Optional[bool]: + """Return the good standing value of the business.""" + if business['corp_state_class'] != 'ACT' or business.get('xpro_jurisdiction', '') in ['AB', 'MB', 'SK']: + # good standing is irrelevant to non active and nwpta businesses + return None + if business['corp_class'] in ['BC'] or business['corp_typ_cd'] in ['LLC', 'LIC', 'A', 'B']: + if business.get('corp_state') in ['D1A', 'D1F', 'D1T', 'D2A', 'D2F', 'D2T', 'LIQ', 'LRL', 'LRS']: + # Dissolution state or Liquidation or Limited Restoration or is NOT in good standing + # - updates into Dissolution states occur irregularly via batch job + # - updates out of these states occur immediately when filing is processed + # (can rely on this for a business being NOT in good standing only) + return False + + requires_transition = business['recognition_dts'] and business['recognition_dts'] < datetime(2004, 3, 29) + if requires_transition and business['transition_dt'] is None: + # Businesses incorporated prior to March 29th, 2004 must file a transition filing + cursor.execute( + """ + SELECT max(f.effective_dt) + FROM event e join filing f on f.event_id = e.event_id + WHERE f.filing_typ_cd in ('RESTF','RESXF') + and e.corp_num=:corp_num + """, corp_num=business['corp_num']) + last_restoration_date = cursor.fetchone() + if last_restoration_date and last_restoration_date[0]: + # restored businesses that require transition have 1 year to do so + return last_restoration_date[0] + datedelta(years=1) > datetime.utcnow() + return False + if last_file_date := (business['last_ar_filed_dt'] or business['recognition_dts']): + # return if the last AR or founding date was within a year and 2 months + return last_file_date + datedelta(years=1, months=2, days=1) > datetime.utcnow() + return None diff --git a/colin-api/src/colin_api/resources/__init__.py b/colin-api/src/colin_api/resources/__init__.py index 306da85aae..c34c3b272d 100644 --- a/colin-api/src/colin_api/resources/__init__.py +++ b/colin-api/src/colin_api/resources/__init__.py @@ -35,7 +35,7 @@ from .share_struct import API as SHARES_API -__all__ = ('API_BLUEPRINT', 'OPS_BLUEPRINT') +__all__ = ('API', 'API_BLUEPRINT', 'OPS_BLUEPRINT') # This will add the Authorize button to the swagger docs AUTHORIZATIONS = { diff --git a/colin-api/src/colin_api/resources/business.py b/colin-api/src/colin_api/resources/business.py index a50480d9ff..06c1a38174 100644 --- a/colin-api/src/colin_api/resources/business.py +++ b/colin-api/src/colin_api/resources/business.py @@ -23,6 +23,7 @@ from colin_api.exceptions import GenericException from colin_api.models import Business, CorpName from colin_api.resources.db import DB +from colin_api.utils.auth import COLIN_SVC_ROLE, jwt from colin_api.utils.util import cors_preflight @@ -37,6 +38,7 @@ class BusinessInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(legal_type: str, identifier: str): """Return the complete business info.""" try: @@ -63,6 +65,7 @@ def get(legal_type: str, identifier: str): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def post(legal_type: str): """Create and return a new corp number for the given legal type.""" if legal_type not in [x.value for x in Business.LearBusinessTypes]: @@ -91,6 +94,7 @@ class BusinessNamesInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(legal_type, identifier, name_type): """Get active names by type code for a business.""" if legal_type not in [x.value for x in Business.LearBusinessTypes]: @@ -124,6 +128,7 @@ class InternalBusinessInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(info_type, legal_type=None, identifier=None): # pylint: disable = too-many-return-statements; """Return specific business info for businesses.""" try: diff --git a/colin-api/src/colin_api/resources/event.py b/colin-api/src/colin_api/resources/event.py index ca980a0804..94351dcdb6 100644 --- a/colin-api/src/colin_api/resources/event.py +++ b/colin-api/src/colin_api/resources/event.py @@ -17,6 +17,7 @@ from colin_api.resources.business import API from colin_api.resources.db import DB +from colin_api.utils.auth import COLIN_SVC_ROLE, jwt from colin_api.utils.util import cors_preflight @@ -27,6 +28,7 @@ class EventInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(corp_type, event_id): """Return all event_ids of the corp_type that are greater than the given event_id.""" querystring = (""" @@ -64,6 +66,7 @@ class CorpEventInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(corp_num): """Return all event_ids of the corp_type that are greater than the given event_id.""" querystring = (""" diff --git a/colin-api/src/colin_api/resources/filing.py b/colin-api/src/colin_api/resources/filing.py index d57be5195b..15f671b95d 100644 --- a/colin-api/src/colin_api/resources/filing.py +++ b/colin-api/src/colin_api/resources/filing.py @@ -19,15 +19,14 @@ from flask import current_app, jsonify, request from flask_restx import Resource, cors -# from registry_schemas import validate # noqa: I001 from colin_api.exceptions import GenericException from colin_api.models import Business from colin_api.models.filing import DB, Filing from colin_api.resources.business import API from colin_api.utils import convert_to_pacific_time +from colin_api.utils.auth import COLIN_SVC_ROLE, jwt from colin_api.utils.util import cors_preflight -# noqa: I003 @cors_preflight('GET, POST') @@ -38,6 +37,7 @@ class FilingInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(legal_type, identifier, filing_type, filing_sub_type=None): """Return the complete filing info or historic (pre-bob-date=2019-03-08) filings.""" try: @@ -86,6 +86,7 @@ def get(legal_type, identifier, filing_type, filing_sub_type=None): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def post(legal_type, identifier, **kwargs): """Create a new filing.""" # pylint: disable=unused-argument,too-many-branches; filing_type is only used for the get diff --git a/colin-api/src/colin_api/resources/office.py b/colin-api/src/colin_api/resources/office.py index d50801e4c8..599bfbf146 100644 --- a/colin-api/src/colin_api/resources/office.py +++ b/colin-api/src/colin_api/resources/office.py @@ -24,6 +24,7 @@ from colin_api.models import Business, Office from colin_api.models.filing import DB from colin_api.resources.business import API +from colin_api.utils.auth import COLIN_SVC_ROLE, jwt from colin_api.utils.util import cors_preflight @@ -34,6 +35,7 @@ class OfficeInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(legal_type: str, identifier: str): """Return the registered and/or records office for a corporation.""" if not identifier: diff --git a/colin-api/src/colin_api/resources/parties.py b/colin-api/src/colin_api/resources/parties.py index 944b461ef8..2bba615053 100644 --- a/colin-api/src/colin_api/resources/parties.py +++ b/colin-api/src/colin_api/resources/parties.py @@ -24,6 +24,7 @@ from colin_api.models import Business, Party from colin_api.models.filing import DB from colin_api.resources.business import API +from colin_api.utils.auth import COLIN_SVC_ROLE, jwt from colin_api.utils.util import cors_preflight @@ -34,6 +35,7 @@ class PartiesInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(legal_type: str, identifier: str): """Return the current directors for a business.""" if not identifier: @@ -69,6 +71,7 @@ class Parties(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(legal_type: str, identifier: str): """Return all the parties for a business.""" try: diff --git a/colin-api/src/colin_api/resources/program_account.py b/colin-api/src/colin_api/resources/program_account.py index 2d29850561..ce76a3a3e4 100644 --- a/colin-api/src/colin_api/resources/program_account.py +++ b/colin-api/src/colin_api/resources/program_account.py @@ -19,6 +19,7 @@ from colin_api.exceptions import GenericException from colin_api.models import ProgramAccount +from colin_api.utils.auth import COLIN_SVC_ROLE, jwt from colin_api.utils.util import cors_preflight @@ -33,6 +34,7 @@ class ProgramAccountInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(identifier: str, transaction_id: str = None): """Return the BNI DB link program account.""" if not identifier: diff --git a/colin-api/src/colin_api/resources/reset.py b/colin-api/src/colin_api/resources/reset.py index 0ebf667978..52ad079185 100644 --- a/colin-api/src/colin_api/resources/reset.py +++ b/colin-api/src/colin_api/resources/reset.py @@ -19,6 +19,7 @@ from flask_restx import Namespace, Resource, cors from colin_api.models.reset import Reset +from colin_api.utils.auth import COLIN_SVC_ROLE, jwt from colin_api.utils.util import cors_preflight @@ -32,6 +33,7 @@ class ResetInfo(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def post(): """Reset the changes in COLIN made by COOPER.""" try: diff --git a/colin-api/src/colin_api/resources/share_struct.py b/colin-api/src/colin_api/resources/share_struct.py index f87b37e10d..b8e94f848f 100644 --- a/colin-api/src/colin_api/resources/share_struct.py +++ b/colin-api/src/colin_api/resources/share_struct.py @@ -24,6 +24,7 @@ from colin_api.models import Business, ShareObject from colin_api.models.filing import DB from colin_api.resources.business import API +from colin_api.utils.auth import COLIN_SVC_ROLE, jwt from colin_api.utils.util import cors_preflight @@ -34,6 +35,7 @@ class ShareStruct(Resource): @staticmethod @cors.crossdomain(origin='*') + @jwt.requires_roles([COLIN_SVC_ROLE]) def get(legal_type: str, identifier: str): """Return the current directors for a business.""" if not identifier: diff --git a/colin-api/src/colin_api/utils/auth.py b/colin-api/src/colin_api/utils/auth.py new file mode 100644 index 0000000000..3a7ab3c733 --- /dev/null +++ b/colin-api/src/colin_api/utils/auth.py @@ -0,0 +1,20 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Bring in the common JWT Manager.""" +from flask_jwt_oidc import JwtManager + + +COLIN_SVC_ROLE = 'colin' + +jwt = JwtManager() # pylint: disable=invalid-name; lower case name as used by convention in most Flask apps diff --git a/colin-api/src/colin_api/utils/util.py b/colin-api/src/colin_api/utils/util.py index bcfcf9b3a4..37ba8ed9f5 100644 --- a/colin-api/src/colin_api/utils/util.py +++ b/colin-api/src/colin_api/utils/util.py @@ -16,7 +16,6 @@ A simple decorator to add the options method to a Request Class. """ -# from functools import wraps def cors_preflight(methods: str = 'GET'): diff --git a/colin-api/src/colin_api/version.py b/colin-api/src/colin_api/version.py index 13627c6fd9..00a921140e 100644 --- a/colin-api/src/colin_api/version.py +++ b/colin-api/src/colin_api/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = '2.80.1' # pylint: disable=invalid-name +__version__ = '2.96.0' # pylint: disable=invalid-name diff --git a/colin-api/tests/postman/colin-api.postman_collection.json b/colin-api/tests/postman/colin-api.postman_collection.json index f52efdc04f..a80b967867 100644 --- a/colin-api/tests/postman/colin-api.postman_collection.json +++ b/colin-api/tests/postman/colin-api.postman_collection.json @@ -10412,6 +10412,136 @@ { "name": "All", "item": [ + { + "name": "businesses", + "item": [ + { + "name": "GET business A", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Response time is less than 10000ms\", function () {", + " pm.expect(pm.response.responseTime).to.be.below(10000);", + "});", + "", + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('should return JSON', function () {", + " pm.response.to.have.header('Content-Type', 'application/json');", + "});", + "", + "pm.test(\"Returns the required fields for business\", () => {", + " var jsonData = pm.response.json()", + " pm.expect(jsonData.business).to.exist", + " pm.expect(jsonData.business.businessNumber).to.exist", + " pm.expect(jsonData.business.email).to.exist", + " pm.expect(jsonData.business.foundingDate).to.exist", + " pm.expect(jsonData.business.identifier).to.eql('A0005599')", + " pm.expect(jsonData.business.jurisdiction).to.eql('BC')", + " pm.expect(jsonData.business.corpState).to.eql('ACT')", + " pm.expect(jsonData.business.status).to.eql('Active')", + " pm.expect(jsonData.business.legalType).to.eql('A')", + " pm.expect(jsonData.business.legalName).to.exist", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json", + "disabled": true + } + ], + "url": { + "raw": "{{url}}/api/v1/businesses/A/A0005599", + "host": [ + "{{url}}" + ], + "path": [ + "api", + "v1", + "businesses", + "A", + "A0005599" + ] + } + }, + "response": [ + { + "name": "GET business CP0002098 CP", + "originalRequest": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "type": "text", + "value": "application/json", + "disabled": true + } + ], + "url": { + "raw": "{{url}}/api/v1/businesses/CP0002098", + "host": [ + "{{url}}" + ], + "path": [ + "api", + "v1", + "businesses", + "CP0002098" + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "Access-Control-Allow-Methods", + "value": "GET, OPTIONS, HEAD" + }, + { + "key": "Access-Control-Max-Age", + "value": "21600" + }, + { + "key": "API", + "value": "colin_api/0.1.0a0.dev" + }, + { + "key": "Server", + "value": "Werkzeug/0.15.4 Python/3.6.5" + }, + { + "key": "Date", + "value": "Mon, 15 Jul 2019 16:02:10 GMT" + } + ], + "cookie": [], + "body": "{\n \"business\": {\n \"businessNumber\": null,\n \"cacheId\": 0,\n \"corpState\": \"ACT\",\n \"foundingDate\": \"2010-10-15\",\n \"identifier\": \"CP0002098\",\n \"jurisdiction\": \"BC\",\n \"lastAgmDate\": \"2017-09-11\",\n \"lastArFiledDate\": \"2017-09-11\",\n \"lastLedgerTimestamp\": \"2017-12-21T00:00:00-00:00\",\n \"legalName\": \"LAKES ARTISAN COOPERATIVE\",\n \"status\": \"Active\",\n \"type\": \"CP\"\n }\n}" + } + ] + } + ] + }, { "name": "parties", "item": [ diff --git a/jobs/update-colin-filings/Makefile b/jobs/update-colin-filings/Makefile index 1710bf8f17..25f630aab1 100644 --- a/jobs/update-colin-filings/Makefile +++ b/jobs/update-colin-filings/Makefile @@ -39,7 +39,7 @@ clean-test: ## clean test files build-req: clean ## Upgrade requirements test -f venv/bin/activate || python3.8 -m venv $(CURRENT_ABS_DIR)/venv ;\ . venv/bin/activate ;\ - pip install pip==20.1.1 ;\ + pip install --upgrade pip ;\ pip install -Ur requirements/prod.txt ;\ pip freeze | sort > requirements.txt ;\ cat requirements/bcregistry-libraries.txt >> requirements.txt ;\ @@ -48,7 +48,7 @@ build-req: clean ## Upgrade requirements install: clean ## Install python virtrual environment test -f venv/bin/activate || python3.8 -m venv $(CURRENT_ABS_DIR)/venv ;\ . venv/bin/activate ;\ - pip install pip==20.1.1 ;\ + pip install --upgrade pip ;\ pip install -Ur requirements.txt install-dev: ## Install local application diff --git a/jobs/update-colin-filings/update_colin_filings.py b/jobs/update-colin-filings/update_colin_filings.py index 58ba6b109e..dcf7118597 100644 --- a/jobs/update-colin-filings/update_colin_filings.py +++ b/jobs/update-colin-filings/update_colin_filings.py @@ -63,15 +63,18 @@ def shell_context(): def get_filings(app: Flask = None): """Get a filing with filing_id.""" - req = requests.get(f'{app.config["LEGAL_URL"]}/internal/filings') + req = requests.get(f'{app.config["LEGAL_URL"]}/internal/filings', + timeout=AccountService.timeout) if not req or req.status_code != 200: - app.logger.error(f'Failed to collect filings from legal-api. {req} {req.json()} {req.status_code}') - raise Exception + app.logger.error( + f'Failed to collect filings from legal-api. {req} {req.json()} {req.status_code}') + raise Exception # pylint: disable=broad-exception-raised return req.json() def send_filing(app: Flask = None, filing: dict = None, filing_id: str = None): """Post to colin-api with filing.""" + token = AccountService.get_bearer_token() clean_none(filing) filing_type = filing['filing']['header'].get('name', None) @@ -79,14 +82,20 @@ def send_filing(app: Flask = None, filing: dict = None, filing_id: str = None): if identifier[:2] == LegalEntity.EntityTypes.COOP.value: entity_type = LegalEntity.EntityTypes.COOP.value else: - entity_type = filing['filing']['business'].get('legalType', LegalEntity.EntityTypes.BCOMP.value) + entity_type = filing['filing']['business'].get( + 'legalType', LegalEntity.EntityTypes.BCOMP.value) req = None if entity_type and identifier and filing_type: - req = requests.post(f'{app.config["COLIN_URL"]}/{entity_type}/{identifier}/filings/{filing_type}', json=filing) + req = requests.post(f'{app.config["COLIN_URL"]}/{entity_type}/{identifier}/filings/{filing_type}', + headers={**AccountService.CONTENT_TYPE_JSON, + 'Authorization': AccountService.BEARER + token}, + json=filing, + timeout=AccountService.timeout) if not req or req.status_code != 201: - app.logger.error(f'Filing {filing_id} not created in colin {identifier}.') + app.logger.error( + f'Filing {filing_id} not created in colin {identifier}.') # raise Exception return None # if it's an AR containing multiple filings it will have multiple colinIds @@ -97,11 +106,13 @@ def update_colin_id(app: Flask = None, filing_id: str = None, colin_ids: list = """Update the colin_id in the filings table.""" req = requests.patch( f'{app.config["LEGAL_URL"]}/internal/filings/{filing_id}', + headers={'Authorization': f'Bearer {token}'}, json={'colinIds': colin_ids}, - headers={'Authorization': f'Bearer {token}'} + timeout=AccountService.timeout ) if not req or req.status_code != 202: - app.logger.error(f'Failed to update colin id in legal db for filing {filing_id} {req.status_code}') + app.logger.error( + f'Failed to update colin id in legal db for filing {filing_id} {req.status_code}') return False return True @@ -128,7 +139,8 @@ def run(): filings = get_filings(app=application) if not filings: # pylint: disable=no-member; false positive - application.logger.debug('No completed filings to send to colin.') + application.logger.debug( + 'No completed filings to send to colin.') for filing in filings: filing_id = filing['filingId'] identifier = filing['filing']['business']['identifier'] @@ -137,17 +149,22 @@ def run(): application.logger.debug(f'Skipping filing {filing_id} for' f' {filing["filing"]["business"]["identifier"]}.') else: - colin_ids = send_filing(app=application, filing=filing, filing_id=filing_id) + colin_ids = send_filing( + app=application, filing=filing, filing_id=filing_id) update = None if colin_ids: - update = update_colin_id(app=application, filing_id=filing_id, colin_ids=colin_ids, token=token) + update = update_colin_id( + app=application, filing_id=filing_id, colin_ids=colin_ids, token=token) if update: # pylint: disable=no-member; false positive - application.logger.debug(f'Successfully updated filing {filing_id}') + application.logger.debug( + f'Successfully updated filing {filing_id}') else: - corps_with_failed_filing.append(filing['filing']['business']['identifier']) + corps_with_failed_filing.append( + filing['filing']['business']['identifier']) # pylint: disable=no-member; false positive - application.logger.error(f'Failed to update filing {filing_id} with colin event id.') + application.logger.error( + f'Failed to update filing {filing_id} with colin event id.') except Exception as err: # noqa: B902 # pylint: disable=no-member; false positive diff --git a/jobs/update-legal-filings/setup.cfg b/jobs/update-legal-filings/setup.cfg index aedbc70212..4eb5d82e24 100644 --- a/jobs/update-legal-filings/setup.cfg +++ b/jobs/update-legal-filings/setup.cfg @@ -7,7 +7,7 @@ per-file-ignores = [pylint] ignore=migrations,test -max_line_length=120 +max-line-length=120 notes=FIXME,XXX,TODO ignored-modules=flask_sqlalchemy,sqlalchemy,SQLAlchemy,alembic,scoped_session ignored-classes=scoped_session diff --git a/jobs/update-legal-filings/update_legal_filings.py b/jobs/update-legal-filings/update_legal_filings.py index 14e720f8da..2a3dd1b630 100644 --- a/jobs/update-legal-filings/update_legal_filings.py +++ b/jobs/update-legal-filings/update_legal_filings.py @@ -43,6 +43,7 @@ event_level=logging.ERROR # send errors as events ) SET_EVENTS_MANUALLY = False +CONTENT_TYPE_JSON = 'application/json' def create_app(run_mode=os.getenv('FLASK_ENV', 'production')): @@ -71,7 +72,7 @@ def shell_context(): def check_for_manual_filings(application: Flask = None, token: dict = None): - # pylint: disable=redefined-outer-name, disable=too-many-branches + # pylint: disable=redefined-outer-name, disable=too-many-branches, disable=too-many-locals """Check for colin filings in oracle.""" id_list = [] colin_events = None @@ -83,7 +84,7 @@ def check_for_manual_filings(application: Flask = None, token: dict = None): Business.TypeCodes.CCC_COMP.value] # get max colin event_id from legal - response = requests.get(f'{legal_url}/internal/filings/colin_id') + response = requests.get(f'{legal_url}/internal/filings/colin_id', timeout=AccountService.timeout) if response.status_code not in [200, 404]: application.logger.error(f'Error getting last updated colin id from \ legal: {response.status_code} {response.json()}') @@ -92,13 +93,20 @@ def check_for_manual_filings(application: Flask = None, token: dict = None): last_event_id = 'earliest' else: last_event_id = dict(response.json())['maxId'] + application.logger.debug(f'last_event_id: {last_event_id}') if last_event_id: last_event_id = str(last_event_id) # get all event_ids greater than above try: for corp_type in corp_types: + application.logger.debug(f'corp_type: {corp_type}') + url = f'{colin_url}/event/{corp_type}/{last_event_id}' + application.logger.debug(f'url: {url}') # call colin api for ids + filing types list - response = requests.get(f'{colin_url}/event/{corp_type}/{last_event_id}') + response = requests.get(url, + headers={**AccountService.CONTENT_TYPE_JSON, + 'Authorization': AccountService.BEARER + token}, + timeout=AccountService.timeout) event_info = dict(response.json()) events = event_info.get('events') if corp_type in no_corp_num_prefix_in_colin: @@ -108,8 +116,8 @@ def check_for_manual_filings(application: Flask = None, token: dict = None): else: colin_events = event_info - except Exception as err: - application.logger.error('Error getting event_ids from colin') + except Exception as err: # noqa: B902 + application.logger.error('Error getting event_ids from colin: %s', repr(err), exc_info=True) raise err # for bringing in a specific filing @@ -127,11 +135,13 @@ def check_for_manual_filings(application: Flask = None, token: dict = None): # check that event is associated with one of the coops loaded into legal db response = requests.get( f'{legal_url}/{info["corp_num"]}', - headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'} + headers={'Content-Type': CONTENT_TYPE_JSON, 'Authorization': f'Bearer {token}'}, + timeout=AccountService.timeout ) if response.status_code == 200: # check legal table - response = requests.get(f'{legal_url}/internal/filings/colin_id/{info["event_id"]}') + response = requests.get(f'{legal_url}/internal/filings/colin_id/{info["event_id"]}', + timeout=AccountService.timeout) if response.status_code == 404: id_list.append(info) elif response.status_code != 200: @@ -148,7 +158,8 @@ def append_corp_num_prefixes(events, corp_num_prefix): event['corp_num'] = corp_num_prefix + event['corp_num'] -def get_filing(event_info: dict = None, application: Flask = None): # pylint: disable=redefined-outer-name +# pylint: disable=redefined-outer-name +def get_filing(event_info: dict = None, application: Flask = None, token: dict = None): """Get filing created by previous event.""" # call the colin api for the filing legal_type = event_info['corp_num'][:2] @@ -158,13 +169,17 @@ def get_filing(event_info: dict = None, application: Flask = None): # pylint: d next((x for x in filing_types if filing_typ_cd in Filing.FILING_TYPES.get(x).get('type_code_list')), None) if not filing_type: + # pylint: disable=consider-using-f-string application.logger.error('Error unknown filing type: {} for event id: {}'.format( event_info['filing_type'], event_info['event_id'])) identifier = event_info['corp_num'] event_id = event_info['event_id'] response = requests.get( - f'{application.config["COLIN_URL"]}/{legal_type}/{identifier}/filings/{filing_type}?eventId={event_id}' + f'{application.config["COLIN_URL"]}/{legal_type}/{identifier}/filings/{filing_type}?eventId={event_id}', + headers={**AccountService.CONTENT_TYPE_JSON, + 'Authorization': AccountService.BEARER + token}, + timeout=AccountService.timeout ) filing = dict(response.json()) return filing @@ -190,14 +205,15 @@ def update_filings(application): # pylint: disable=redefined-outer-name, too-ma # Make sure this coop has no outstanding filings that failed to be applied. # This ensures we don't apply filings out of order when one fails. if event_info['corp_num'] not in corps_with_failed_filing: - filing = get_filing(event_info, application) + filing = get_filing(event_info, application, token) # call legal api with filing application.logger.debug(f'sending filing with event info: {event_info} to legal api.') response = requests.post( f'{application.config["LEGAL_URL"]}/{event_info["corp_num"]}/filings', json=filing, - headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'} + headers={'Content-Type': CONTENT_TYPE_JSON, 'Authorization': f'Bearer {token}'}, + timeout=AccountService.timeout ) if response.status_code != 201: if not first_failed_id: @@ -230,10 +246,11 @@ def update_filings(application): # pylint: disable=redefined-outer-name, too-ma max_event_id = first_failed_id - 1 if max_event_id > 0: # update max_event_id in legal_db - application.logger.debug('setting last_event_id in legal_db to {}'.format(max_event_id)) + application.logger.debug(f'setting last_event_id in legal_db to {max_event_id}') response = requests.post( f'{application.config["LEGAL_URL"]}/internal/filings/colin_id/{max_event_id}', - headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'} + headers={'Content-Type': CONTENT_TYPE_JSON, 'Authorization': f'Bearer {token}'}, + timeout=AccountService.timeout ) if response.status_code != 201: application.logger.error( @@ -248,7 +265,7 @@ def update_filings(application): # pylint: disable=redefined-outer-name, too-ma else: application.logger.debug('colin_last_update not updated in legal db.') - except Exception as err: + except Exception as err: # noqa: B902 application.logger.error('Update-legal-filings: unhandled error %s', err) @@ -305,11 +322,12 @@ async def update_business_nos(application): # pylint: disable=redefined-outer-n application.logger.debug('Getting businesses with outstanding tax ids from legal api...') response = requests.get( application.config['LEGAL_URL'] + '/internal/tax_ids', - headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'} + headers={'Content-Type': CONTENT_TYPE_JSON, 'Authorization': f'Bearer {token}'}, + timeout=AccountService.timeout ) if response.status_code != 200: application.logger.error('legal-updater failed to get identifiers from legal-api.') - raise Exception + raise Exception # pylint: disable=broad-exception-raised identifiers = response.json() if identifiers['identifiers']: @@ -318,11 +336,12 @@ async def update_business_nos(application): # pylint: disable=redefined-outer-n response = requests.get( application.config['COLIN_URL'] + '/internal/tax_ids', json=identifiers, - headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'} + headers={'Content-Type': CONTENT_TYPE_JSON, 'Authorization': f'Bearer {token}'}, + timeout=AccountService.timeout ) if response.status_code != 200: application.logger.error('legal-updater failed to get tax_ids from colin-api.') - raise Exception + raise Exception # pylint: disable=broad-exception-raised tax_ids = response.json() if tax_ids.keys(): # update lear with new tax ids from colin @@ -330,11 +349,12 @@ async def update_business_nos(application): # pylint: disable=redefined-outer-n response = requests.post( application.config['LEGAL_URL'] + '/internal/tax_ids', json=tax_ids, - headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'} + headers={'Content-Type': CONTENT_TYPE_JSON, 'Authorization': f'Bearer {token}'}, + timeout=AccountService.timeout ) if response.status_code != 201: application.logger.error('legal-updater failed to update tax_ids in lear.') - raise Exception + raise Exception # pylint: disable=broad-exception-raised await publish_queue_events(tax_ids, application) @@ -344,7 +364,7 @@ async def update_business_nos(application): # pylint: disable=redefined-outer-n else: application.logger.debug('No businesses in lear with outstanding tax ids.') - except Exception as err: + except Exception as err: # noqa: B902 application.logger.error(err) diff --git a/legal-api/Makefile b/legal-api/Makefile index 37e2570c84..172c9aef00 100644 --- a/legal-api/Makefile +++ b/legal-api/Makefile @@ -49,11 +49,11 @@ install: clean ## Install python virtrual environment test -f venv/bin/activate || python3 -m venv $(CURRENT_ABS_DIR)/venv ;\ . venv/bin/activate ;\ pip install --upgrade pip ;\ - pip install -Ur requirements.txt + pip install -Ur $(CURRENT_ABS_DIR)/requirements.txt install-dev: ## Install local application . venv/bin/activate ; \ - pip install -Ur requirements/dev.txt; \ + pip install -Ur $(CURRENT_ABS_DIR)/requirements/dev.txt; \ pip install -e . ################################################################################# diff --git a/legal-api/gunicorn_config.py b/legal-api/gunicorn_config.py index 8b422aca4b..092a5ef43f 100755 --- a/legal-api/gunicorn_config.py +++ b/legal-api/gunicorn_config.py @@ -16,6 +16,7 @@ """ import os +import gunicorn_server workers = int(os.environ.get("GUNICORN_PROCESSES", "1")) # pylint: disable=invalid-name threads = int(os.environ.get("GUNICORN_THREADS", "1")) # pylint: disable=invalid-name @@ -23,3 +24,6 @@ forwarded_allow_ips = "*" # pylint: disable=invalid-name secure_scheme_headers = {"X-Forwarded-Proto": "https"} # pylint: disable=invalid-name + +# Server Hooks +pre_fork = gunicorn_server.pre_fork diff --git a/legal-api/gunicorn_server.py b/legal-api/gunicorn_server.py new file mode 100644 index 0000000000..31e124b99e --- /dev/null +++ b/legal-api/gunicorn_server.py @@ -0,0 +1,10 @@ + +import time + + +def pre_fork(server, worker): + # Delay loading of each worker by 5 seconds + # This is done to work around an issue where the Traction API is returning an invalid token. The issue happens + # when successive token retrieval calls are made with less than 2-3 seconds between the calls. + time.sleep(5) + diff --git a/legal-api/migrations/versions/5238dd8fb805_.py b/legal-api/migrations/versions/5238dd8fb805_.py new file mode 100644 index 0000000000..6a88913448 --- /dev/null +++ b/legal-api/migrations/versions/5238dd8fb805_.py @@ -0,0 +1,73 @@ +"""amalgamation + +Revision ID: 5238dd8fb805 +Revises: 60d9c14c2b7f +Create Date: 2023-12-13 16:28:23.390151 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = '5238dd8fb805' +down_revision = '60d9c14c2b7f' +branch_labels = None +depends_on = None + +role_enum = postgresql.ENUM('amalgamating', 'holding', 'primary', name='amalgamating_business_role') +amalgamation_type_enum = postgresql.ENUM('regular', + 'vertical', + 'horizontal', + name='amalgamation_type') + + +def upgrade(): + + # add enum values + role_enum.create(op.get_bind(), checkfirst=True) + amalgamation_type_enum.create(op.get_bind(), checkfirst=True) + + # ========================================================================================== + # amalgamating_business/amalgamation tables + # ========================================================================================== + + op.create_table( + 'amalgamation', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('business_id', sa.Integer(), nullable=False), + sa.Column('filing_id', sa.Integer(), nullable=False), + sa.Column('amalgamation_date', sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column('court_approval', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['filing_id'], ['filings.id']), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id']), + sa.PrimaryKeyConstraint('id')) + + # enum added after creating table as DuplicateObject error would be thrown otherwise + op.add_column('amalgamation', sa.Column('amalgamation_type', amalgamation_type_enum, nullable=False)) + + op.create_table( + 'amalgamating_business', + sa.Column('id', sa.Integer(), primary_key=False), + sa.Column('business_id', sa.Integer(), nullable=True), + sa.Column('amalgamation_id', sa.Integer(), nullable=False), + sa.Column('foreign_jurisdiction', sa.String(length=10), nullable=True), + sa.Column('foreign_jurisdiction_region', sa.String(length=10), nullable=True), + sa.Column('foreign_name', sa.String(length=100), nullable=True), + sa.Column('foreign_corp_num', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['business_id'], ['businesses.id']), + sa.ForeignKeyConstraint(['amalgamation_id'], ['amalgamation.id']), + sa.PrimaryKeyConstraint('id')) + + # enum added after creating table as DuplicateObject error would be thrown otherwise + op.add_column('amalgamating_business', sa.Column('role', role_enum, nullable=False)) + + +def downgrade(): + op.drop_table('amalgamating_business') + op.drop_table('amalgamation') + + # Drop enum types from the database + amalgamation_type_enum.drop(op.get_bind(), checkfirst=True) + role_enum.drop(op.get_bind(), checkfirst=True) diff --git a/legal-api/pyproject.toml b/legal-api/pyproject.toml index 0cf045f679..25b8a8c9b4 100644 --- a/legal-api/pyproject.toml +++ b/legal-api/pyproject.toml @@ -29,6 +29,7 @@ gunicorn = "^20.1.0" jsonschema = "^4.17.3" pycountry = "^22.3.5" pydantic = "^1.10.8" +pyjwt==2.8.0 reportlab = "^4.0.0" requests = "^2.31.0" strict-rfc3339 = "^0.7" diff --git a/legal-api/report-templates/amalgamationApplication.html b/legal-api/report-templates/amalgamationApplication.html new file mode 100644 index 0000000000..cb19b9c7a9 --- /dev/null +++ b/legal-api/report-templates/amalgamationApplication.html @@ -0,0 +1,13 @@ +[[macros.html]] + + + + + Amalgamation Application + + + [[common/style.html]] + + + + \ No newline at end of file diff --git a/legal-api/report-templates/certificateOfAmalgamation.html b/legal-api/report-templates/certificateOfAmalgamation.html new file mode 100644 index 0000000000..a41bff288b --- /dev/null +++ b/legal-api/report-templates/certificateOfAmalgamation.html @@ -0,0 +1,14 @@ +[[macros.html]] + + + + + Certificate of Amalgamation + + + [[common/certificateStyle.html]] + + + + [[common/certificateFooter.html]] + \ No newline at end of file diff --git a/legal-api/report-templates/certificateOfNameChange.html b/legal-api/report-templates/certificateOfNameChange.html index ff06e803fe..db22d2fee6 100644 --- a/legal-api/report-templates/certificateOfNameChange.html +++ b/legal-api/report-templates/certificateOfNameChange.html @@ -13,6 +13,9 @@
Incorporation Number: {{ business.identifier }}
+ {% if correctedOn is defined %} + Corrected on: {{ correctedOn }} + {% endif %}
diff --git a/legal-api/report-templates/correction.html b/legal-api/report-templates/correction.html index ed532f2d25..b67e6f7489 100644 --- a/legal-api/report-templates/correction.html +++ b/legal-api/report-templates/correction.html @@ -47,8 +47,12 @@ {% if shareClasses and (shareClassesChange or newShareClasses or ceasedShareClasses) %} [[common/shareStructure.html]] {% endif %} - + [[common/resolutionDates.html]] + + [[correction/associateType.html]] + [[correction/rulesMemorandum.html]] + [[correction/resolution.html]]
diff --git a/legal-api/report-templates/letterOfAgmExtension.html b/legal-api/report-templates/letterOfAgmExtension.html new file mode 100644 index 0000000000..e346ce4849 --- /dev/null +++ b/legal-api/report-templates/letterOfAgmExtension.html @@ -0,0 +1,65 @@ +[[macros.html]] + + + + Letter of AGM Extension + + + [[common/style.html]] + [[common/styleLetterOverride.html]] + + + + +
+
{{ report_date }}
+ {% set office = offices.registeredOffice %} +
{{ office.mailingAddress.streetAddress }}
+
{{ office.mailingAddress.streetAddressAdditional }}
+
+ {{ office.mailingAddress.addressCity }}, {{ office.mailingAddress.addressRegion }} +
+
{{ office.mailingAddress.postalCode }}
+
Dear Applicant,
+
Re: {{ business.legalName }} - {{ business.identifier }}
+
+ {% if is_first_agm %} + Under section 182(4) of the Business Corporations Act, I hereby allow the company to extend the time + within which the above named company is required to hold its first Annual General Meeting + {% elif is_final_agm %} + Under section 182(4) of the Business Corporations Act, I hereby allow the company to further extend the time + within which the above named company is required to hold its Annual General Meeting for the year {{agm_year}} + {% else %} + Under section 182(4) of the Business Corporations Act, I hereby allow the company to extend the time + within which the above named company is required to hold its Annual General Meeting for the year {{agm_year}} + {% endif %} + + by {{duration_spelling}} ({{duration_numeric}}) + + {% if duration_numeric==1 %} + month + {% else %} + months + {% endif %} + + from {{original_agm_date}} to {{extended_agm_date}}. + +
+
+ {% if is_final_agm %} + Please note, this is your final extension. + {% endif %} +
+

Yours truly,

+
+
[[common/certificateRegistrarSignature.html]]
+
+
{{ registrarInfo.name }}
+
{{ registrarInfo.title }}
+
+
+
+ [[common/footerMOCS.html]] + + + diff --git a/legal-api/report-templates/letterOfAgmLocationChange.html b/legal-api/report-templates/letterOfAgmLocationChange.html new file mode 100644 index 0000000000..4806a34b2e --- /dev/null +++ b/legal-api/report-templates/letterOfAgmLocationChange.html @@ -0,0 +1,43 @@ +[[macros.html]] + + + + Letter of AGM Location Change + + + [[common/style.html]] + [[common/styleLetterOverride.html]] + + + + +
+
{{ report_date }}
+ {% set office = offices.registeredOffice %} +
{{ office.mailingAddress.streetAddress }}
+
{{ office.mailingAddress.streetAddressAdditional }}
+
+ {{ office.mailingAddress.addressCity }}, {{ office.mailingAddress.addressRegion }} +
+
{{ office.mailingAddress.postalCode }}
+
Dear Applicant,
+
Re: {{ business.legalName }} - {{ business.identifier }}
+
+ Under section 166(b)(iii) of the Business Corporations Act, I hereby approve the following location + as the place where the above-named company may convene its Annual General Meeting for the year {{agm_year}}. +
+
+ {{location}} +
+

Yours truly,

+
+
[[common/certificateRegistrarSignature.html]]
+
+
{{ registrarInfo.name }}
+
{{ registrarInfo.title }}
+
+
+
+ [[common/footerMOCS.html]] + + diff --git a/legal-api/report-templates/specialResolutionCorrectionApplication.html b/legal-api/report-templates/specialResolutionCorrectionApplication.html index c4da308929..9cd2759b77 100644 --- a/legal-api/report-templates/specialResolutionCorrectionApplication.html +++ b/legal-api/report-templates/specialResolutionCorrectionApplication.html @@ -28,7 +28,7 @@
[[correction/businessDetails.html]]
- [[special-resolution/resolutionApplicationCorrection.html]] + [[special-resolution/resolutionApplication.html]]
diff --git a/legal-api/report-templates/template-parts/common/businessDetails.html b/legal-api/report-templates/template-parts/common/businessDetails.html index 93c724bcb6..1984065db7 100644 --- a/legal-api/report-templates/template-parts/common/businessDetails.html +++ b/legal-api/report-templates/template-parts/common/businessDetails.html @@ -67,14 +67,12 @@
Incorporation Number:
Filed Date and Time:
-
Special Resolution Type:
Resolution Date:
Retrieved Date and Time:
{{business.identifier}}
{{filing_date_time}}
-
{{header.displayName}}
{{specialResolution.resolutionDate}}
{{report_date_time}}
diff --git a/legal-api/report-templates/template-parts/correction/associateType.html b/legal-api/report-templates/template-parts/correction/associateType.html new file mode 100644 index 0000000000..70d24a5cbe --- /dev/null +++ b/legal-api/report-templates/template-parts/correction/associateType.html @@ -0,0 +1,18 @@ +{% if prevCoopAssociationType %} +
+
Change of Association Type CORRECTED
+
+ + + + + + +
+{% endif %} diff --git a/legal-api/report-templates/template-parts/correction/resolution.html b/legal-api/report-templates/template-parts/correction/resolution.html new file mode 100644 index 0000000000..b15ee06ff1 --- /dev/null +++ b/legal-api/report-templates/template-parts/correction/resolution.html @@ -0,0 +1,7 @@ +{% if correction.resolution %} +
+
Special Resolution CORRECTED
+
+
Changes will be described in the special resolution text.
+
+{% endif %} diff --git a/legal-api/report-templates/template-parts/correction/rulesMemorandum.html b/legal-api/report-templates/template-parts/correction/rulesMemorandum.html new file mode 100644 index 0000000000..d403070c75 --- /dev/null +++ b/legal-api/report-templates/template-parts/correction/rulesMemorandum.html @@ -0,0 +1,23 @@ +{% if rulesInResolution or uploadNewRules %} +
+
Rules CORRECTED
+
+{% if uploadNewRules %} +
A certified copy of the uploaded rules is attached with this filing.
+{% else %} +
Changes will be described in the special resolution text.
+{% endif %} +
+{% endif %} + +{% if memorandumInResolution or uploadNewMemorandum %} +
+
Memorandum CORRECTED
+
+{% if uploadNewMemorandum %} +
A certified copy of the uploaded memorandum is attached with this filing.
+{% else %} +
Changes will be described in the special resolution text.
+{% endif %} +
+{% endif %} diff --git a/legal-api/report-templates/template-parts/registration/completingParty.html b/legal-api/report-templates/template-parts/registration/completingParty.html index d3970d86da..05736bee08 100644 --- a/legal-api/report-templates/template-parts/registration/completingParty.html +++ b/legal-api/report-templates/template-parts/registration/completingParty.html @@ -9,15 +9,7 @@
- {% if party.officer.partyType == 'organization' %} - {{ party.officer.organizationName }} - {% else %} - {{ party.officer.lastName }}, - {{ party.officer.firstName }} - {% if party.officer.middleName %} - {{ party.officer.middleName }} - {% endif %} - {% endif %} + {{ party.officer.name }}
{% if party.mailingAddress is defined %} diff --git a/legal-api/report-templates/template-parts/special-resolution/resolutionApplicationCorrection.html b/legal-api/report-templates/template-parts/special-resolution/resolutionApplicationCorrection.html deleted file mode 100644 index 8a7fafb0b3..0000000000 --- a/legal-api/report-templates/template-parts/special-resolution/resolutionApplicationCorrection.html +++ /dev/null @@ -1,72 +0,0 @@ -
-{% if toBusinessName %} -
-
Change of Business Name CORRECTED
-
- - - - - - - - - -
-{% endif %} - -{% if prevCoopAssociationType %} -
-
Change of Association Type CORRECTED
-
- - - - - - -
-{% endif %} - -{% if rulesInResolution or uploadNewRules %} -
-
Rules CORRECTED
-
-{% if uploadNewRules %} -
A certified copy of the uploaded rules is attached with this filing.
-{% else %} -
Changes will be described in the special resolution text.
-{% endif %} -
-{% endif %} - -{% if memorandumInResolution %} -
-
Memoranum CORRECTED
-
-
Changes will be described in the special resolution text.
-
-{% endif %} - -{% if correction.resolution %} -
-
Special Resolution CORRECTED
-
-
Changes will be described in the special resolution text.
-
-{% endif %} diff --git a/legal-api/requirements.txt.1 b/legal-api/requirements.txt.1 deleted file mode 100644 index 167cdacaab..0000000000 --- a/legal-api/requirements.txt.1 +++ /dev/null @@ -1,52 +0,0 @@ -Babel==2.9.1 -Flask-Babel==2.0.0 -Flask-Migrate==3.1.0 -Flask-Moment==1.0.2 -Flask-SQLAlchemy==2.5.1 -Flask-Script==2.0.6 -Flask==2.0.1 -Jinja2==3.0.1 -Mako==1.1.5 -MarkupSafe==2.0.1 -SQLAlchemy-Continuum==1.3.11 -SQLAlchemy-Utils==0.37.8 -SQLAlchemy==1.4.23 -Werkzeug==2.0.1 -alembic==1.7.3 -aniso8601==9.0.1 -asyncio-nats-client==0.11.4 -asyncio-nats-streaming==0.4.0 -attrs==21.2.0 -blinker==1.4 -cachelib==0.3.0 -certifi==2021.5.30 -charset-normalizer==2.0.6 -click==8.0.1 -datedelta==1.3 -dpath==2.0.5 -ecdsa==0.17.0 -expiringdict==1.1.4 -flask-jwt-oidc==0.3.0 -flask-restx==0.5.1 -gunicorn==20.1.0 -idna==3.2 -itsdangerous==2.0.1 -jsonschema==3.2.0 -launchdarkly-server-sdk==7.2.0 -minio==7.1.0 -protobuf==3.18.0 -psycopg2-binary==2.9.1 -pyRFC3339==1.1 -pyasn1==0.4.8 -pycountry==20.7.3 -pyrsistent==0.18.0 -python-dotenv==0.19.0 -python-jose==3.3.0 -pytz==2021.1 -requests==2.26.0 -rsa==4.7.2 -semver==2.13.0 -sentry-sdk==1.4.0 -six==1.16.0 -strict-rfc3339==0.7 -urllib3==1.26.6 diff --git a/legal-api/requirements/bcregistry-libraries.txt b/legal-api/requirements/bcregistry-libraries.txt index cca4834bda..aeb50703bd 100644 --- a/legal-api/requirements/bcregistry-libraries.txt +++ b/legal-api/requirements/bcregistry-libraries.txt @@ -1 +1 @@ -git+https://github.com/bcgov/business-schemas.git@2.18.10#egg=registry_schemas +git+https://github.com/bcgov/business-schemas.git@2.18.18#egg=registry_schemas diff --git a/legal-api/src/legal_api/config.py b/legal-api/src/legal_api/config.py index 8ed7cd7f93..4ab975fcce 100644 --- a/legal-api/src/legal_api/config.py +++ b/legal-api/src/legal_api/config.py @@ -168,6 +168,22 @@ class _Config: # pylint: disable=too-few-public-methods SENTRY_DSN = os.getenv("SENTRY_DSN", None) LD_SDK_KEY = os.getenv("BUSINESS_API_LD_SDK_KEY", None) + # Traction ACA-Py tenant settings to issue credentials from + TRACTION_API_URL = os.getenv("TRACTION_API_URL") + TRACTION_TENANT_ID = os.getenv("TRACTION_TENANT_ID") + TRACTION_API_KEY = os.getenv("TRACTION_API_KEY") + TRACTION_PUBLIC_SCHEMA_DID = os.getenv("TRACTION_PUBLIC_SCHEMA_DID") + TRACTION_PUBLIC_ISSUER_DID = os.getenv("TRACTION_PUBLIC_ISSUER_DID") + + # Web socket settings + WS_ALLOWED_ORIGINS = os.getenv("WS_ALLOWED_ORIGINS") + + # Digital Business Card configuration values (required to issue credentials) + BUSINESS_SCHEMA_NAME = os.getenv("BUSINESS_SCHEMA_NAME") + BUSINESS_SCHEMA_VERSION = os.getenv("BUSINESS_SCHEMA_VERSION") + BUSINESS_SCHEMA_ID = os.getenv("BUSINESS_SCHEMA_ID") + BUSINESS_CRED_DEF_ID = os.getenv("BUSINESS_CRED_DEF_ID") + TESTING = False DEBUG = False diff --git a/legal-api/src/legal_api/core/filing.py b/legal-api/src/legal_api/core/filing.py index de1f817d1d..b550313384 100644 --- a/legal-api/src/legal_api/core/filing.py +++ b/legal-api/src/legal_api/core/filing.py @@ -57,6 +57,8 @@ class FilingTypes(str, Enum): """Render an Enum of all Filing Types.""" ADMIN_FREEZE = "adminFreeze" + AGMEXTENSION = "agmExtension" + AGMLOCATIONCHANGE = "agmLocationChange" ALTERATION = "alteration" AMALGAMATIONAPPLICATION = "amalgamationApplication" AMENDEDAGM = "amendedAGM" @@ -86,6 +88,19 @@ class FilingTypes(str, Enum): SPECIALRESOLUTION = "specialResolution" TRANSITION = "transition" + class FilingTypesCompact(str, Enum): + """Render enum for filing types with sub-types.""" + + DISSOLUTION_VOLUNTARY = "dissolution.voluntary" + DISSOLUTION_ADMINISTRATIVE = "dissolution.administrative" + RESTORATION_FULL_RESTORATION = "restoration.fullRestoration" + RESTORATION_LIMITED_RESTORATION = "restoration.limitedRestoration" + RESTORATION_LIMITED_RESTORATION_EXT = "restoration.limitedRestorationExtension" + RESTORATION_LIMITED_RESTORATION_TO_FULL = "restoration.limitedRestorationToFull" + AMALGAMATION_APPLICATION_REGULAR = "amalgamationApplication.regular" + AMALGAMATION_APPLICATION_VERTICAL = "amalgamationApplication.vertical" + AMALGAMATION_APPLICATION_HORIZONTAL = "amalgamationApplication.horizontal" + def __init__(self): """Create the Filing.""" self._storage: Optional[FilingStorage] = None @@ -425,6 +440,7 @@ def common_ledger_items(business_identifier: str, filing_storage: FilingStorage) filing = Filing() filing._storage = filing_storage # pylint: disable=protected-access return { + "displayLedger": Filing._is_display_ledger(filing_storage), "commentsCount": filing_storage.comments_count, "commentsLink": f"{base_url}/{business_identifier}/filings/{filing_storage.id}/comments", "documentsLink": f"{base_url}/{business_identifier}/filings/{filing_storage.id}/documents" @@ -448,6 +464,10 @@ def _add_ledger_order(filing: FilingStorage, ledger_filing: dict) -> dict: ledger_filing["data"] = {} ledger_filing["data"]["order"] = court_order_data + def _is_display_ledger(filing: FilingStorage) -> bool: + """Return boolean that display the ledger.""" + return filing.filing_type != Filing.FilingTypes.ADMIN_FREEZE + @staticmethod def get_document_list( # pylint: disable=too-many-locals disable=too-many-branches legal_entity, filing, request @@ -496,6 +516,8 @@ def get_document_list( # pylint: disable=too-many-locals disable=too-many-branc Filing.FilingTypes.REGISTRATION.value, Filing.FilingTypes.CONSENTCONTINUATIONOUT.value, Filing.FilingTypes.CONTINUATIONOUT.value, + Filing.FilingTypes.AGMEXTENSION.value, + Filing.FilingTypes.AGMLOCATIONCHANGE.value, ] if filing.status == Filing.Status.PAID and not ( filing.filing_type in no_legal_filings_in_paid_status @@ -538,6 +560,8 @@ def get_document_list( # pylint: disable=too-many-locals disable=too-many-branc no_legal_filings = [ Filing.FilingTypes.CONSENTCONTINUATIONOUT.value, Filing.FilingTypes.CONTINUATIONOUT.value, + Filing.FilingTypes.AGMEXTENSION.value, + Filing.FilingTypes.AGMLOCATIONCHANGE.value, ] if filing.filing_type not in no_legal_filings: documents["documents"]["legalFilings"] = [ diff --git a/legal-api/src/legal_api/core/filing_helper.py b/legal-api/src/legal_api/core/filing_helper.py index 7bb4c06ecc..1ff10e126f 100644 --- a/legal-api/src/legal_api/core/filing_helper.py +++ b/legal-api/src/legal_api/core/filing_helper.py @@ -1,28 +1,32 @@ """Helper function for filings.""" from typing import Dict -from legal_api.models.legal_entity import LegalEntity - -def is_special_resolution_correction(filing: Dict, legal_entity, original_filing): +def is_special_resolution_correction_by_meta_data(filing): """Check whether it is a special resolution correction.""" - # Avoid circular import. - from legal_api.models import Filing # pylint: disable=import-outside-toplevel + # Check by using the meta_data, this is more permanent than the filing json. + # This is used by reports (after the filer). + if filing.meta_data and (correction_meta_data := filing.meta_data.get("correction")): + # Note these come from the corrections filer. + sr_correction_meta_data_keys = ["hasResolution", "memorandumInResolution", "rulesInResolution", + "uploadNewRules", "uploadNewMemorandum", + "toCooperativeAssociationType", "toLegalName"] + for key in sr_correction_meta_data_keys: + if key in correction_meta_data: + return True + return False - corrected_filing_type = filing["correction"].get("correctedFilingType") - if isinstance(legal_entity, LegalEntity) and legal_entity.entity_type != LegalEntity.EntityTypes.COOP.value: - return False - if isinstance(legal_entity, dict) and legal_entity.get("legalType") != LegalEntity.EntityTypes.COOP.value: - return False - if corrected_filing_type == "specialResolution": +def is_special_resolution_correction_by_filing_json(filing: Dict): + """Check whether it is a special resolution correction.""" + # Note this relies on the filing data once. This is acceptable inside of the filer (which runs once) + # and emailer (runs on PAID which is before the filer and runs on COMPLETED). + # For filing data that persists in the database, attempt to use the meta_data instead. + sr_correction_keys = ["rulesInResolution", "resolution", "rulesFileKey", "memorandumFileKey", + "memorandumInResolution", "cooperativeAssociationType"] + for key in sr_correction_keys: + if key in filing.get("correction"): + return True + if "requestType" in filing.get("correction", {}).get("nameRequest", {}): return True - if corrected_filing_type not in ("specialResolution", "correction"): - return False - if not original_filing: - return False - - # Find the next original filing in the chain of corrections - filing = original_filing.filing_json["filing"] - original_filing = Filing.find_by_id(filing["correction"]["correctedFilingId"]) - return is_special_resolution_correction(filing, legal_entity, original_filing) + return False diff --git a/legal-api/src/legal_api/core/meta/filing.py b/legal-api/src/legal_api/core/meta/filing.py index ae23d0455e..fbf8ad7856 100644 --- a/legal-api/src/legal_api/core/meta/filing.py +++ b/legal-api/src/legal_api/core/meta/filing.py @@ -17,7 +17,6 @@ from enum import Enum, auto from typing import Final, MutableMapping, Optional -from legal_api.core.filing_helper import is_special_resolution_correction from legal_api.models import Filing as FilingStorage from legal_api.models import LegalEntity from legal_api.services import ( # noqa: I005 @@ -73,8 +72,37 @@ class FilingTitles(str, Enum): FILINGS: Final = { +<<<<<<< HEAD "adminFreeze": {"name": "adminFreeze", "title": "Admin Freeze", "displayName": "Admin Freeze", "code": "NOFEE"}, "affidavit": {"name": "affidavit", "title": "Affidavit", "codes": {"CP": "AFDVT"}}, + "agmExtension": { + "name": "agmExtension", + "title": "AGM Extension", + "displayName": "Request for AGM Extension", + "codes": { + "BC": "AGMDT", + "BEN": "AGMDT", + "ULC": "AGMDT", + "CC": "AGMDT" + }, + "additional": [ + {"types": "BC, BEN, CC, ULC", "outputs": ["letterOfAgmExtension", ]}, + ], + }, + "agmLocationChange": { + "name": "agmLocationChange", + "title": "AGM Location Change", + "displayName": "AGM Location Change", + "codes": { + "BC": "AGMLC", + "BEN": "AGMLC", + "ULC": "AGMLC", + "CC": "AGMLC" + }, + "additional": [ + {"types": "BC, BEN, CC, ULC", "outputs": ["letterOfAgmLocationChange", ]}, + ] + }, "alteration": { "name": "alteration", "title": "Notice of Alteration Filing", @@ -89,6 +117,45 @@ class FilingTitles(str, Enum): }, ], }, + "amalgamationApplication": { + "name": "amalgamationApplication", + "additional": [ + {"types": "BC,ULC,BEN,CC", "outputs": ["noticeOfArticles", "certificateOfAmalgamation"]}, + ], + "regular": { + "name": "regularAmalgamation", + "title": "Regular Amalgamation", + "displayName": "Regular Amalgamation", + "codes": { + "BEN": "AMALR", + "BC": "AMALR", + "ULC": "AMALR", + "CC": "AMALR" + } + }, + "vertical": { + "name": "verticalAmalgamation", + "title": "Vertical Amalgamation", + "displayName": "Vertical Amalgamation", + "codes": { + "BEN": "AMALV", + "BC": "AMALV", + "ULC": "AMALV", + "CC": "AMALV" + } + }, + "horizontal": { + "name": "horizontalAmalgamation", + "title": "Horizontal Amalgamation", + "displayName": "Horizontal Amalgamation", + "codes": { + "BEN": "AMALH", + "BC": "AMALH", + "ULC": "AMALH", + "CC": "AMALH" + } + } + }, "annualReport": { "name": "annualReport", "title": "Annual Report Filing", @@ -426,9 +493,7 @@ def alter_outputs(filing: FilingStorage, business: LegalEntity, outputs: set): outputs = FilingMeta.alter_outputs_alteration(filing, outputs) outputs = FilingMeta.alter_outputs_correction(filing, business, outputs) outputs = FilingMeta.alter_outputs_special_resolution(filing, outputs) - if filing.filing_type == "dissolution" and filing.filing_sub_type == "administrative": - # Supress Certificate of Dissolution for Admin Dissolution - outputs.remove("certificateOfDissolution") + outputs = FilingMeta.alter_outputs_dissolution(filing, outputs) @staticmethod def alter_outputs_alteration(filing, outputs): @@ -444,26 +509,36 @@ def alter_outputs_correction(filing, business, outputs): if filing.filing_type == "correction": if filing.meta_data.get("correction", {}).get("toBusinessName"): outputs.add("certificateOfNameChange") - corrected_filing_id = filing.filing_json.get("correction", {}).get("correctedFilingId") - original_filing = FilingStorage.find_by_id(corrected_filing_id) - if is_special_resolution_correction(filing.filing_json["filing"], business, original_filing): - if filing.filing_json["filing"]["correction"].get("rulesFileKey"): - outputs.add("certifiedRules") - if filing.filing_json["filing"]["correction"].get("resolution"): - outputs.add("specialResolution") + if filing.meta_data.get("correction", {}).get("uploadNewRules"): + outputs.add("certifiedRules") + if filing.meta_data.get("correction", {}).get("uploadNewMemorandum"): + outputs.add("certifiedMemorandum") + if filing.meta_data.get("correction", {}).get("hasResolution"): + outputs.add("specialResolution") + return outputs + + @staticmethod + def alter_outputs_dissolution(filing, outputs): + """Handle output file list modification for dissolution.""" + if filing.filing_type == "dissolution": + # Suppress Certificate of Dissolution for Admin Dissolution + if filing.filing_sub_type == "administrative": + outputs.remove("certificateOfDissolution") + # Suppress Certified Memorandum and Certified Rules for Coop Voluntary Dissolution + if filing.filing_sub_type == "voluntary" and filing.json_legal_type == LegalEntity.EntityTypes.COOP: + outputs.remove("certifiedRules") + outputs.remove("certifiedMemorandum") return outputs @staticmethod def alter_outputs_special_resolution(filing, outputs): """Handle output file list modification for special resolution.""" if filing.filing_type == "specialResolution": + outputs.remove("certifiedMemorandum") if "changeOfName" in filing.meta_data.get("legalFilings", []): outputs.add("certificateOfNameChange") - if "alteration" in filing.meta_data.get("legalFilings", []): - if filing.filing_json["filing"]["alteration"].get("memorandumInResolution") is True: - outputs.remove("certifiedMemorandum") - if filing.filing_json["filing"]["alteration"].get("rulesInResolution") is True: - outputs.remove("certifiedRules") + if not filing.meta_data.get("alteration", {}).get("uploadNewRules"): + outputs.remove("certifiedRules") return outputs @staticmethod @@ -488,13 +563,11 @@ def get_corrected_filing_name(filing: FilingStorage, business_revision: LegalEnt corrected_filing_type = filing.filing_json["filing"]["correction"]["correctedFilingType"] corrected_filing_id = filing.filing_json["filing"]["correction"]["correctedFilingId"] - if corrected_filing_type in ["annualReport", "specialResolution"]: + if corrected_filing_type in ["annualReport"]: corrected_filing = FilingStorage.find_by_id(corrected_filing_id) display_name = FilingMeta.display_name(business_revision, corrected_filing) if corrected_filing_type == "annualReport": return f"Correction - {display_name}" - else: - return f"{display_name} Correction" elif corrected_filing_type == "correction": corrected_filing = FilingStorage.find_by_id(corrected_filing_id) return FilingMeta.get_corrected_filing_name(corrected_filing, business_revision, name) diff --git a/legal-api/src/legal_api/decorators.py b/legal-api/src/legal_api/decorators.py new file mode 100644 index 0000000000..9e5b8e443c --- /dev/null +++ b/legal-api/src/legal_api/decorators.py @@ -0,0 +1,78 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module holds function decorators.""" + +import json +from datetime import datetime +from functools import wraps +from http import HTTPStatus + +import jwt as pyjwt +import requests +from flask import current_app, jsonify +from jwt import ExpiredSignatureError + +from legal_api.models import Business +from legal_api.services.authz import are_digital_credentials_allowed +from legal_api.utils.auth import jwt + + +def requires_traction_auth(f): + """Check for a valid Traction token and refresh if needed.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not (traction_api_url := current_app.config["TRACTION_API_URL"]): + raise EnvironmentError("TRACTION_API_URL environment variable is not set") + + if not (traction_tenant_id := current_app.config["TRACTION_TENANT_ID"]): + raise EnvironmentError("TRACTION_TENANT_ID environment variable is not set") + + if not (traction_api_key := current_app.config["TRACTION_API_KEY"]): + raise EnvironmentError("TRACTION_API_KEY environment variable is not set") + + try: + if not hasattr(current_app, "api_token"): + raise pyjwt.ExpiredSignatureError + + if not (decoded := pyjwt.decode(current_app.api_token, options={"verify_signature": False})): + raise pyjwt.ExpiredSignatureError + + if datetime.utcfromtimestamp(decoded["exp"]) <= datetime.utcnow(): + raise pyjwt.ExpiredSignatureError + except ExpiredSignatureError: + current_app.logger.info("JWT token expired or is missing, requesting new token") + response = requests.post(f"{traction_api_url}/multitenancy/tenant/{traction_tenant_id}/token", + headers={"Content-Type": "application/json"}, + data=json.dumps({"api_key": traction_api_key})) + response.raise_for_status() + current_app.api_token = response.json()["token"] + + return f(*args, **kwargs) + return decorated_function + + +def can_access_digital_credentials(f): + """Ensure the business can has access to digital credentials.""" + @wraps(f) + def decorated_function(*args, **kwargs): + identifier = kwargs.get("identifier", None) + + if not (business := Business.find_by_identifier(identifier)): + return jsonify({"message": f"{identifier} not found."}), HTTPStatus.NOT_FOUND + + if not are_digital_credentials_allowed(business, jwt): + return jsonify({"message": f"digital credential not available for: {identifier}."}), HTTPStatus.UNAUTHORIZED + + return f(*args, **kwargs) + return decorated_function diff --git a/legal-api/src/legal_api/models/__init__.py b/legal-api/src/legal_api/models/__init__.py index 106e8e3f46..49a61665ab 100644 --- a/legal-api/src/legal_api/models/__init__.py +++ b/legal-api/src/legal_api/models/__init__.py @@ -14,6 +14,8 @@ """This exports all of the models and schemas used by the application.""" from .address import Address +from .amalgamating_business import AmalgamatingBusiness +from .amalgamation import Amalgamation from .alias import Alias from .alternate_name import AlternateName from .colin_entity import ColinEntity @@ -24,7 +26,9 @@ from .db import db # noqa: I001 from .dc_connection import DCConnection from .dc_definition import DCDefinition +from .dc_issued_business_user_credential import DCIssuedBusinessUserCredential from .dc_issued_credential import DCIssuedCredential +from .dc_revocation_reason import DCRevocationReason from .document import Document, DocumentType from .entity_role import EntityRole from .filing import Filing @@ -44,6 +48,8 @@ __all__ = ( "db", "Address", + "AmalgamatingBusiness", + "Amalgamation", "Alias", "AlternateName", "ColinEntity", @@ -54,7 +60,9 @@ "CorpType", "DCConnection", "DCDefinition", + "DCIssuedBusinessUserCredential", "DCIssuedCredential", + "DCRevocationReason", "Document", "DocumentType", "EntityRole", diff --git a/legal-api/src/legal_api/models/amalgamating_business.py b/legal-api/src/legal_api/models/amalgamating_business.py new file mode 100644 index 0000000000..0b47464c09 --- /dev/null +++ b/legal-api/src/legal_api/models/amalgamating_business.py @@ -0,0 +1,55 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Meta information about the service. + +Currently this only provides API versioning information +""" + +from enum import auto + +from ..utils.base import BaseEnum +from .db import db + + +class AmalgamatingBusiness(db.Model): # pylint: disable=too-many-instance-attributes + """This class manages the amalgamating businesses.""" + + # pylint: disable=invalid-name + class Role(BaseEnum): + """Enum for the Role Values.""" + + amalgamating = auto() + holding = auto() + primary = auto() + + # __versioned__ = {} + __tablename__ = 'amalgamating_business' + + id = db.Column(db.Integer, primary_key=True) + role = db.Column('role', db.Enum(Role), nullable=False) + foreign_jurisdiction = db.Column('foreign_jurisdiction', db.String(10)) + foreign_jurisdiction_region = db.Column('foreign_jurisdiction_region', db.String(10)) + foreign_name = db.Column('foreign_name', db.String(100)) + foreign_corp_num = db.Column('foreign_corp_num', db.String(50)) + + # parent keys + legal_entity_id = db.Column("legal_entity_id", db.Integer, db.ForeignKey("legal_entities.id")) + amalgamation_id = db.Column('amalgamation_id', db.Integer, db.ForeignKey('amalgamation.id', + ondelete='CASCADE'), + nullable=False) + + def save(self): + """Save the object to the database immediately.""" + db.session.add(self) + db.session.commit() diff --git a/legal-api/src/legal_api/models/amalgamation.py b/legal-api/src/legal_api/models/amalgamation.py new file mode 100644 index 0000000000..36dba56ea2 --- /dev/null +++ b/legal-api/src/legal_api/models/amalgamation.py @@ -0,0 +1,76 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Meta information about the service. + +Currently this only provides API versioning information +""" +from __future__ import annotations + +from enum import auto + +from ..utils.base import BaseEnum +from .db import db + + +class Amalgamation(db.Model): # pylint: disable=too-many-instance-attributes + """This class manages the amalgamations.""" + + # pylint: disable=invalid-name + class AmalgamationTypes(BaseEnum): + """Enum for the amalgamation type.""" + + regular = auto() + vertical = auto() + horizontal = auto() + + # __versioned__ = {} + __tablename__ = 'amalgamation' + + id = db.Column(db.Integer, primary_key=True) + amalgamation_type = db.Column('amalgamation_type', db.Enum(AmalgamationTypes), nullable=False) + amalgamation_date = db.Column('amalgamation_date', db.DateTime(timezone=True), nullable=False) + court_approval = db.Column('court_approval', db.Boolean()) + + # parent keys + legal_entity_id = db.Column("legal_entity_id", db.Integer, db.ForeignKey("legal_entities.id")) + filing_id = db.Column('filing_id', db.Integer, db.ForeignKey('filings.id'), nullable=False) + + # Relationships + amalgamating_businesses = db.relationship('AmalgamatingBusiness', lazy='dynamic') + + def save(self): + """Save the object to the database immediately.""" + db.session.add(self) + db.session.commit() + + @classmethod + def find_by_id(cls, amalgamation_id) -> Amalgamation: + """Return amalgamation by the id.""" + amalgamation = None + if amalgamation_id: + amalgamation = cls.query.filter_by(id=amalgamation_id).one_or_none() + return amalgamation + + def json(self): + """Return amalgamation json.""" + from .legal_entity import LegalEntity # pylint: disable=import-outside-toplevel + legal_entity = LegalEntity.find_by_internal_id(self.legal_entity_id) + + return { + 'amalgamationDate': self.amalgamation_date.isoformat(), + 'amalgamationType': self.amalgamation_type.name, + 'courtApproval': self.court_approval, + 'identifier': legal_entity.identifier, + 'legalName': legal_entity.legal_name + } diff --git a/legal-api/src/legal_api/models/dc_connection.py b/legal-api/src/legal_api/models/dc_connection.py index d04415e3c3..e317f22936 100644 --- a/legal-api/src/legal_api/models/dc_connection.py +++ b/legal-api/src/legal_api/models/dc_connection.py @@ -14,6 +14,7 @@ """This module holds data for digital credentials connection.""" from __future__ import annotations +from enum import Enum from typing import List from .db import db @@ -22,6 +23,18 @@ class DCConnection(db.Model): # pylint: disable=too-many-instance-attributes """This class manages the digital credentials connection.""" + class State(Enum): + """Enum of the connection states.""" + + INIT = "init" + INVITATION = "invitation" + REQUEST = "request" + RESPONSE = "response" + ACTIVE = "active" + COMPLETED = "completed" + INACTIVE = "inactive" + ERROR = "error" + __tablename__ = "dc_connections" id = db.Column(db.Integer, primary_key=True) @@ -53,6 +66,11 @@ def save(self): db.session.add(self) db.session.commit() + def delete(self): + """Delete the object from the database immediately.""" + db.session.delete(self) + db.session.commit() + @classmethod def find_by_id(cls, dc_connection_id: str) -> DCConnection: """Return the digital credential connection matching the id.""" diff --git a/legal-api/src/legal_api/models/dc_definition.py b/legal-api/src/legal_api/models/dc_definition.py index 034de2a39e..6dab47cf3f 100644 --- a/legal-api/src/legal_api/models/dc_definition.py +++ b/legal-api/src/legal_api/models/dc_definition.py @@ -81,16 +81,15 @@ def find_by_credential_type(cls, credential_type: CredentialType) -> DCDefinitio return dc_definition @classmethod - def find_by(cls, credential_type: CredentialType, schema_name: str, schema_version: str) -> DCDefinition: + def find_by(cls, credential_type: CredentialType, schema_id: str, credential_definition_id: str) -> DCDefinition: """Return the digital credential definition matching the filter.""" query = ( db.session.query(DCDefinition) - .filter(DCDefinition.credential_type == credential_type) - .filter(DCDefinition.schema_name == schema_name) - .filter(DCDefinition.schema_version == schema_version) - ) - - return query.first() + .filter(DCDefinition.is_deleted == False) # noqa: E712 # pylint: disable=singleton-comparison + .filter(DCDefinition.credential_type == credential_type) + .filter(DCDefinition.schema_id == schema_id) + .filter(DCDefinition.credential_definition_id == credential_definition_id)) + return query.one_or_none() @classmethod def deactivate(cls, credential_type: CredentialType): diff --git a/legal-api/src/legal_api/models/dc_issued_business_user_credential.py b/legal-api/src/legal_api/models/dc_issued_business_user_credential.py new file mode 100644 index 0000000000..9a01194dc4 --- /dev/null +++ b/legal-api/src/legal_api/models/dc_issued_business_user_credential.py @@ -0,0 +1,57 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module holds data for issued credential.""" +from __future__ import annotations + +from typing import List + +from .db import db + + +class DCIssuedBusinessUserCredential(db.Model): # pylint: disable=too-many-instance-attributes + """This class manages the issued credential IDs for a user of a business.""" + + __tablename__ = 'dc_issued_business_user_credentials' + + id = db.Column(db.Integer, primary_key=True) + + user_id = db.Column('user_id', db.Integer, db.ForeignKey('users.id')) + legal_entity_id = db.Column("legal_entity_id", db.Integer, db.ForeignKey("legal_entities.id")) + + def save(self): + """Save the object to the database immediately.""" + db.session.add(self) + db.session.commit() + + @classmethod + def find_by_id(cls, dc_issued_business_user_id: str) -> DCIssuedBusinessUserCredential: + """Return the issued business user credential matching the id.""" + dc_issued_business_user = None + if dc_issued_business_user_id: + dc_issued_business_user = cls.query.filter_by(id=dc_issued_business_user_id).one_or_none() + return dc_issued_business_user + + @classmethod + def find_by(cls, + legal_entity_id: int = None, + user_id: int = None) -> List[DCIssuedBusinessUserCredential]: + """Return the issued business user credential matching the user_id and buisness_id.""" + dc_issued_business_user_credential = None + if legal_entity_id and user_id: + dc_issued_business_user_credential = ( + cls.query + .filter(DCIssuedBusinessUserCredential.legal_entity_id == legal_entity_id) + .filter(DCIssuedBusinessUserCredential.user_id == user_id) + .one_or_none()) + return dc_issued_business_user_credential diff --git a/legal-api/src/legal_api/models/dc_issued_credential.py b/legal-api/src/legal_api/models/dc_issued_credential.py index 537b2663e8..ebf5390449 100644 --- a/legal-api/src/legal_api/models/dc_issued_credential.py +++ b/legal-api/src/legal_api/models/dc_issued_credential.py @@ -30,11 +30,13 @@ class DCIssuedCredential(db.Model): # pylint: disable=too-many-instance-attribu dc_connection_id = db.Column("dc_connection_id", db.Integer, db.ForeignKey("dc_connections.id")) credential_exchange_id = db.Column("credential_exchange_id", db.String(100)) - credential_id = db.Column("credential_id", db.String(100)) # not in use + credential_id = db.Column("credential_id", db.String(10)) is_issued = db.Column("is_issued", db.Boolean, default=False) date_of_issue = db.Column("date_of_issue", db.DateTime(timezone=True)) is_revoked = db.Column("is_revoked", db.Boolean, default=False) + credential_revocation_id = db.Column("credential_revocation_id", db.String(10)) + revocation_registry_id = db.Column("revocation_registry_id", db.String(200)) @property def json(self): @@ -44,9 +46,12 @@ def json(self): "dcDefinitionId": self.dc_definition_id, "dcConnectionId": self.dc_connection_id, "credentialExchangeId": self.credential_exchange_id, + "credentialId": self.credential_id, "isIssued": self.is_issued, - "dateOfIssue": self.date_of_issue.isoformat(), + "dateOfIssue": self.date_of_issue.isoformat() if self.date_of_issue else None, "isRevoked": self.is_revoked, + "credentialRevocationId": self.credential_revocation_id, + "revocationRegistryId": self.revocation_registry_id } return dc_issued_credential @@ -55,6 +60,11 @@ def save(self): db.session.add(self) db.session.commit() + def delete(self): + """Delete the object from the database immediately.""" + db.session.delete(self) + db.session.commit() + @classmethod def find_by_id(cls, dc_issued_credential_id: str) -> DCIssuedCredential: """Return the issued credential matching the id.""" @@ -65,7 +75,7 @@ def find_by_id(cls, dc_issued_credential_id: str) -> DCIssuedCredential: @classmethod def find_by_credential_exchange_id(cls, credential_exchange_id: str) -> DCIssuedCredential: - """Return the issued credential matching the id.""" + """Return the issued credential matching the credential exchange id.""" dc_issued_credential = None if credential_exchange_id: dc_issued_credential = cls.query.filter( @@ -73,6 +83,15 @@ def find_by_credential_exchange_id(cls, credential_exchange_id: str) -> DCIssued ).first() return dc_issued_credential + @classmethod + def find_by_credential_id(cls, credential_id: str) -> DCIssuedCredential: + """Return the issued credential matching the credential id.""" + dc_issued_credential = None + if credential_id: + dc_issued_credential = cls.query. \ + filter(DCIssuedCredential.credential_id == credential_id).one_or_none() + return dc_issued_credential + @classmethod def find_by(cls, dc_definition_id: int = None, dc_connection_id: int = None) -> List[DCIssuedCredential]: """Return the issued credential matching the filter.""" diff --git a/legal-api/src/legal_api/models/dc_revocation_reason.py b/legal-api/src/legal_api/models/dc_revocation_reason.py new file mode 100644 index 0000000000..9362f67e87 --- /dev/null +++ b/legal-api/src/legal_api/models/dc_revocation_reason.py @@ -0,0 +1,29 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""This module holds revocation reasons for issued credential.""" +from enum import Enum + + +class DCRevocationReason(Enum): + """Digital Credential Revocation Reasons.""" + + ADMINISTRATIVE_REVOCATION = "Your credential was revoked." + UPDATED_INFORMATION = "You were offered a new credential with updated information " \ + "and that revoked all previous copies." + VOLUNTARY_DISSOLUTION = "You chose to dissolve your business. " \ + "A new credential was offered that reflects the new company status and that revoked all previous copies." + ADMINISTRATIVE_DISSOLUTION = "Your business was dissolved by the Registrar." + PUT_BACK_ON = "Your business was put back on the Registry. " + SELF_REISSUANCE = "You chose to issue yourself a new credential and that revoked all previous copies." + SELF_REVOCATION = "You chose to revoke your own credential." diff --git a/legal-api/src/legal_api/models/filing.py b/legal-api/src/legal_api/models/filing.py index 453dc61abf..21e429f848 100644 --- a/legal-api/src/legal_api/models/filing.py +++ b/legal-api/src/legal_api/models/filing.py @@ -1,11 +1,8 @@ # Copyright © 2019 Province of British Columbia -# # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at -# # http://www.apache.org/licenses/LICENSE-2.0 -# # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -62,11 +59,65 @@ class Source(Enum): # TODO: get legal types from defined class once table is made (getting it from Business causes circ import) FILINGS = { "affidavit": {"name": "affidavit", "title": "Affidavit", "codes": {"CP": "AFDVT"}}, + "agmExtension": { + "name": "agmExtension", + "title": "AGM Extension", + "codes": { + "BC": "AGMDT", + "BEN": "AGMDT", + "ULC": "AGMDT", + "CC": "AGMDT" + } + }, + "agmLocationChange": { + "name": "agmLocationChange", + "title": "AGM Change of Location", + "codes": { + "BC": "AGMLC", + "BEN": "AGMLC", + "ULC": "AGMLC", + "CC": "AGMLC" + } + }, "alteration": { "name": "alteration", "title": "Notice of Alteration Filing", "codes": {"BC": "ALTER", "BEN": "ALTER", "ULC": "ALTER", "CC": "ALTER"}, }, + "amalgamationApplication": { + "name": "amalgamationApplication", + "temporaryCorpTypeCode": "ATMP", + "regular": { + "name": "regularAmalgamation", + "title": "Regular Amalgamation", + "codes": { + "BEN": "AMALR", + "BC": "AMALR", + "ULC": "AMALR", + "CC": "AMALR" + }, + }, + "vertical": { + "name": "verticalAmalgamation", + "title": "Vertical Amalgamation", + "codes": { + "BEN": "AMALV", + "BC": "AMALV", + "ULC": "AMALV", + "CC": "AMALV" + }, + }, + "horizontal": { + "name": "horizontalAmalgamation", + "title": "Horizontal Amalgamation", + "codes": { + "BEN": "AMALH", + "BC": "AMALH", + "ULC": "AMALH", + "CC": "AMALH" + }, + } + }, "annualReport": { "name": "annualReport", "title": "Annual Report Filing", @@ -209,6 +260,7 @@ class Source(Enum): # breaking and more testing was req'd so did not make refactor when introducing this dictionary. "dissolution": "dissolutionType", "restoration": "type", + "amalgamationApplication": "type" } __tablename__ = "filings" @@ -898,19 +950,14 @@ def receive_before_change(mapper, connection, target): # pylint: disable=unused # because it's been set to PENDING_CORRECTION by the entity filer. if hasattr(filing, "skip_status_listener") and filing.skip_status_listener: return - # changes are part of the class and are not externalized if filing.filing_type == "lear_epoch": filing._status = Filing.Status.EPOCH.value # pylint: disable=protected-access - elif filing.transaction_id: filing._status = Filing.Status.COMPLETED.value # pylint: disable=protected-access - elif filing.payment_completion_date or filing.source == Filing.Source.COLIN.value: filing._status = Filing.Status.PAID.value # pylint: disable=protected-access - elif filing.payment_token: filing._status = Filing.Status.PENDING.value # pylint: disable=protected-access - else: filing._status = Filing.Status.DRAFT.value # pylint: disable=protected-access diff --git a/legal-api/src/legal_api/models/legal_entity.py b/legal-api/src/legal_api/models/legal_entity.py index 40cc857f02..3b11031e44 100644 --- a/legal-api/src/legal_api/models/legal_entity.py +++ b/legal-api/src/legal_api/models/legal_entity.py @@ -42,6 +42,7 @@ from .alternate_name import ( # noqa: F401 pylint: disable=unused-import; needed by SQLAlchemy relationship AlternateName, ) +from .amalgamation import Amalgamation # noqa: F401 pylint: disable=unused-import; needed by SQLAlchemy relationship from .db import db # noqa: I001 from .entity_role import ( # noqa: F401 pylint: disable=unused-import; needed by the SQLAlchemy relationship EntityRole, @@ -325,6 +326,8 @@ class AssociationTypes(Enum): foreign_keys="Resolution.signing_legal_entity_id", lazy="dynamic", ) + amalgamating_businesses = db.relationship('AmalgamatingBusiness', lazy='dynamic') + amalgamation = db.relationship('Amalgamation', lazy='dynamic') @hybrid_property def identifier(self): @@ -653,7 +656,12 @@ def _extend_json(self, d): if self.fiscal_year_end_date: d["fiscalYearEndDate"] = datetime.date(self.fiscal_year_end_date).isoformat() if self.state_filing_id: - d["stateFiling"] = f"{base_url}/{self.identifier}/filings/{self.state_filing_id}" + if (self.state == LegalEntity.State.HISTORICAL and + (amalgamating_business := self.amalgamating_businesses.one_or_none())): + amalgamation = Amalgamation.find_by_id(amalgamating_business.amalgamation_id) + d["amalgamatedInto"] = amalgamation.json() + else: + d["stateFiling"] = f"{base_url}/{self.identifier}/filings/{self.state_filing_id}" if self.start_date: d["startDate"] = LegislationDatetime.format_as_legislation_date(self.start_date) diff --git a/legal-api/src/legal_api/reports/report.py b/legal-api/src/legal_api/reports/report.py index e83174ea3f..2dda13fa24 100644 --- a/legal-api/src/legal_api/reports/report.py +++ b/legal-api/src/legal_api/reports/report.py @@ -21,9 +21,9 @@ import pycountry import requests +from dateutil.relativedelta import relativedelta from flask import current_app, jsonify -from legal_api.core.filing_helper import is_special_resolution_correction from legal_api.core.meta.filing import FILINGS from legal_api.models import ( ConsentContinuationOut, @@ -37,6 +37,7 @@ from legal_api.reports.registrar_meta import RegistrarInfo from legal_api.services import MinioService, VersionedBusinessDetailsService from legal_api.utils.auth import jwt +from legal_api.utils.formatting import float_to_str from legal_api.utils.legislation_datetime import LegislationDatetime OUTPUT_DATE_FORMAT: Final = "%B %-d, %Y" @@ -65,7 +66,11 @@ def _get_static_report(self): document_type = ReportMeta.static_reports[self._report_key]["documentType"] document: Document = self._filing.documents.filter(Document.type == document_type).first() response = MinioService.get_file(document.file_key) - return response.data, response.status + return current_app.response_class( + response=response.data, + status=response.status, + mimetype="application/pdf" + ) def _get_report(self): if self._filing.legal_entity_id: @@ -83,7 +88,12 @@ def _get_report(self): if response.status_code != HTTPStatus.OK: return jsonify(message=str(response.content)), response.status_code - return response.content, response.status_code + + return current_app.response_class( + response=response.content, + status=response.status_code, + mimetype="application/pdf" + ) def _get_report_filename(self): filing_date = str(self._filing.filing_date)[:19] @@ -141,8 +151,11 @@ def _substitute_template_parts(template_code): "common/completingParty", "correction/businessDetails", "correction/addresses", + "correction/associateType", "correction/directors", "correction/legalNameChange", + "correction/resolution", + "correction/rulesMemorandum", "change-of-registration/legal-name", "change-of-registration/nature-of-business", "change-of-registration/addresses", @@ -184,7 +197,6 @@ def _substitute_template_parts(template_code): "alteration-notice/companyProvisions", "special-resolution/resolution", "special-resolution/resolutionApplication", - "special-resolution/resolutionApplicationCorrection", "addresses", "certification", "directors", @@ -207,14 +219,7 @@ def _get_template_filename(self): if ReportMeta.reports[self._report_key].get("hasDifferentTemplates", False): # Get template specific to legal type file_name = None - specific_template = ReportMeta.reports[self._report_key].get(self._legal_entity.entity_type, None) - if self._legal_entity.entity_type == "CP" and self._filing.filing_type == "correction": - corrected_filing_id = self._filing.filing_json["filing"].get("correction", {}).get("correctedFilingId") - original_filing = Filing.find_by_id(corrected_filing_id) - if is_special_resolution_correction( - self._filing.filing_json["filing"], self._legal_entity, original_filing - ): - file_name = "specialResolutionCorrectionApplication" + specific_template = ReportMeta.reports[self._report_key].get(self._business.legal_type, None) if file_name is None: # Fallback to default if specific template not found file_name = ( @@ -240,6 +245,7 @@ def _get_template_data(self): filing["header"]["reportType"] = self._report_key + self._format_par_value(filing) self._set_dates(filing) self._set_description(filing) self._set_tax_id(filing) @@ -248,6 +254,12 @@ def _get_template_data(self): self._set_completing_party(filing) return filing + def _format_par_value(self, filing): + if share_classes := filing.get("shareClasses"): + for share_class in share_classes: + if (par_value := share_class.get("parValue")) and isinstance(par_value, float): + share_class["parValue"] = float_to_str(par_value) + def _format_filing_json(self, filing): # pylint: disable=too-many-branches, too-many-statements if self._report_key == "incorporationApplication": self._format_incorporation_data(filing) @@ -273,6 +285,14 @@ def _format_filing_json(self, filing): # pylint: disable=too-many-branches, too self._format_transition_data(filing) elif self._report_key == "dissolution": self._format_dissolution_data(filing) + elif self._report_key == "letterOfAgmExtension": + self._format_agm_extension_data(filing) + elif self._report_key == "letterOfAgmLocationChange": + self._format_agm_location_change_data(filing) + elif self._report_key == "amalgamationApplication": + self._format_amalgamation_data(filing) + elif self._report_key == "certificateOfAmalgamation": + self._format_certificate_of_amalgamation_data(filing) else: # set registered office address from either the COA filing or status quo data in AR filing with suppress(KeyError): @@ -476,12 +496,18 @@ def _format_incorporation_data(self, filing): cooperative["cooperativeAssociationType"], "" ) + def _set_party_name(self, parties): + for party in parties: + party["officer"]["name"] = self._get_party_name(party) + def _format_registration_data(self, filing): with suppress(KeyError): self._format_address(filing["registration"]["offices"]["businessOffice"]["deliveryAddress"]) with suppress(KeyError): + self._format_address(filing["registration"]["offices"]["businessOffice"]["mailingAddress"]) self._format_directors(filing["registration"]["parties"]) + self._set_party_name(filing["registration"]["parties"]) start_date = LegislationDatetime.as_legislation_timezone_from_date_str(filing["registration"]["startDate"]) filing["registration"]["startDate"] = start_date.strftime(OUTPUT_DATE_FORMAT) @@ -493,6 +519,11 @@ def _format_name_change_data(self, filing): if self._filing.filing_type == "alteration": from_business_name = meta_data.get("alteration", {}).get("fromBusinessName") to_business_name = meta_data.get("alteration", {}).get("toBusinessName") + if self._filing.filing_type == "correction": + from_business_name = meta_data.get("correction", {}).get("fromBusinessName") + to_business_name = meta_data.get("correction", {}).get("toBusinessName") + corrected_on = LegislationDatetime.as_legislation_timezone(self._filing.filing_date) + filing["correctedOn"] = corrected_on.strftime(OUTPUT_DATE_FORMAT) if self._filing.filing_type == "specialResolution" and "changeOfName" in meta_data.get("legalFilings", []): from_business_name = meta_data.get("changeOfName", {}).get("fromBusinessName") to_business_name = meta_data.get("changeOfName", {}).get("toBusinessName") @@ -582,6 +613,49 @@ def _format_consent_continuation_out_data(self, filing): with suppress(KeyError): self._format_address(filing["offices"]["registeredOffice"]["mailingAddress"]) + def _format_agm_extension_data(self, filing): + meta_data = self._filing.meta_data or {} + is_first_agm = meta_data.get("agmExtension", {}).get("isFirstAgm", "") + filing["is_first_agm"] = is_first_agm + filing["agm_year"] = meta_data.get("agmExtension", {}).get("year", "") + filing["is_final_agm"] = meta_data.get("agmExtension", {}).get("isFinalExtension", "") + + number_words = ["one", "two", "three", "four", "five", "six"] + duration_numeric = meta_data.get("agmExtension", {}).get("extensionDuration", "") + filing["duration_numeric"] = duration_numeric + filing["duration_spelling"] = number_words[int(duration_numeric)-1] + + if is_first_agm: + founding_date_json = self._filing.filing_json["filing"].get("business", {}).get("foundingDate", "") + founding_date = founding_date_json[0:10] + original_date_time = LegislationDatetime.\ + as_legislation_timezone_from_date_str(founding_date) + relativedelta(months=18) + filing["original_agm_date"] = original_date_time.strftime(OUTPUT_DATE_FORMAT) + else: + expire_date_current_string = meta_data.get("agmExtension", {}).get("expireDateCurrExt", "") + date_current_obj = LegislationDatetime.as_legislation_timezone_from_date_str(expire_date_current_string) + filing["original_agm_date"] = date_current_obj.strftime(OUTPUT_DATE_FORMAT) + + if expire_date_approved_string := meta_data.get("agmExtension", {}).get("expireDateApprovedExt", ""): + date_approved_obj = LegislationDatetime.as_legislation_timezone_from_date_str(expire_date_approved_string) + filing["extended_agm_date"] = date_approved_obj.strftime(OUTPUT_DATE_FORMAT) + + filing["offices"] = VersionedBusinessDetailsService.\ + get_office_revision(self._filing.transaction_id, self._business.id) + with suppress(KeyError): + self._format_address(filing["offices"]["registeredOffice"]["mailingAddress"]) + + def _format_agm_location_change_data(self, filing): + filing["agm_year"] = self._filing.filing_json["filing"].get("agmLocationChange", {}).get("year", "") + + filing["location"] = self._filing.filing_json["filing"].get("agmLocationChange", {}).get("agmLocation", "") + + filing["offices"] = VersionedBusinessDetailsService.\ + get_office_revision(self._filing.transaction_id, self._business.id) + + with suppress(KeyError): + self._format_address(filing["offices"]["registeredOffice"]["mailingAddress"]) + def _format_alteration_data(self, filing): # Get current list of translations in alteration. None if it is deletion if "nameTranslations" in filing["alteration"]: @@ -623,9 +697,16 @@ def _format_alteration_data(self, filing): ) filing["newLegalTypeDescription"] = self._get_legal_type_description(new_legal_type) if new_legal_type else None - def _format_change_of_registration_data( - self, filing, filing_type - ): # noqa: E501 # pylint: disable=too-many-locals, too-many-branches, too-many-statements + def _format_amalgamation_data(self, filling): + # FUTURE: format logic for amalgamation application + return + + def _format_certificate_of_amalgamation_data(self, filing): + # FUTURE: format logic for certificate of amalgamation + return + + def _format_change_of_registration_data(self, filing, filing_type): # noqa: E501 # pylint: disable=too-many-locals, too-many-branches, too-many-statements + prev_completed_filing = Filing.get_previous_completed_filing(self._filing) versioned_legal_entity = VersionedBusinessDetailsService.get_business_revision_obj( prev_completed_filing, self._legal_entity.id @@ -720,10 +801,11 @@ def _get_party_name(party_json): if party_json.get("officer").get("partyType") == "person": last_name = party_json["officer"].get("lastName") first_name = party_json["officer"].get("firstName") - middle_initial = ( - party_json["officer"].get("middleInitial") if party_json["officer"].get("middleInitial") else "" - ) - party_name = f"{last_name}, {first_name} {middle_initial}" + middle_name = party_json["officer"].get("middleName", party_json["officer"].get("middleInitial", "")) + if not middle_name and not first_name: + party_name = f"{last_name}" + else: + party_name = f"{last_name}, {first_name} {middle_name}" elif party_json.get("officer").get("partyType") == "organization": party_name = party_json["officer"].get("organizationName") return party_name @@ -791,12 +873,8 @@ def _has_change(self, old_value, new_value): return has_change def _format_correction_data(self, filing): - corrected_filing_id = filing.get("correction", {}).get("correctedFilingId") - original_filing = Filing.find_by_id(corrected_filing_id) if self._legal_entity.legal_type in ["SP", "GP"]: self._format_change_of_registration_data(filing, "correction") - elif is_special_resolution_correction(filing, self._legal_entity, original_filing): - self._format_special_resolution_application(filing, "correction") else: prev_completed_filing = Filing.get_previous_completed_filing(self._filing) versioned_legal_entity = VersionedBusinessDetailsService.get_business_revision_obj( @@ -808,6 +886,7 @@ def _format_correction_data(self, filing): self._format_office_data(filing, prev_completed_filing) self._format_party_data(filing, prev_completed_filing) self._format_share_class_data(filing, prev_completed_filing) + self._format_resolution_data(filing) def _format_name_request_data(self, filing, versioned_legal_entity: LegalEntity): name_request_json = filing.get("correction").get("nameRequest", {}) @@ -824,7 +903,9 @@ def _format_name_translations_data(self, filing, prev_completed_filing: Filing): prev_completed_filing, self._legal_entity.id ) filing["previousNameTranslations"] = versioned_name_translations - filing["nameTranslationsChange"] = sorted(filing["listOfTranslations"]) != sorted(versioned_name_translations) + filing["nameTranslationsChange"] = \ + sorted([translation["name"] for translation in filing["listOfTranslations"]]) != \ + sorted([translation["name"] for translation in versioned_name_translations]) def _format_office_data(self, filing, prev_completed_filing: Filing): filing["offices"] = {} @@ -965,6 +1046,22 @@ def _format_share_series_data( if ceased_share_series: filing["shareClassesChange"] = True + def _format_resolution_data(self, filing: Filing): + meta_data = self._filing.meta_data or {} + filing_source = "correction" + prev_association_type = meta_data.get(filing_source, {}).get("fromCooperativeAssociationType") + to_association_type = meta_data.get(filing_source, {}).get("toCooperativeAssociationType") + if prev_association_type and to_association_type and prev_association_type != to_association_type: + filing["prevCoopAssociationType"] = ASSOCIATION_TYPE_DESC.get(prev_association_type, "") + filing["newCoopAssociationType"] = ASSOCIATION_TYPE_DESC.get(to_association_type, "") + filing["rulesInResolution"] = filing.get(filing_source, {}).get("rulesInResolution") + filing["uploadNewRules"] = meta_data.get(filing_source, {}).get("uploadNewRules") + filing["uploadNewMemorandum"] = meta_data.get(filing_source, {}).get("uploadNewMemorandum") + filing["memorandumInResolution"] = filing.get(filing_source, {}).get("memorandumInResolution") + if (resolution_date_str := filing.get(filing_source, {}).get("resolutionDate", None)): + resolution_date = LegislationDatetime.as_legislation_timezone_from_date_str(resolution_date_str) + filing[filing_source]["resolutionDate"] = resolution_date.strftime(OUTPUT_DATE_FORMAT) + @staticmethod def _compare_json(new_json, existing_json, excluded_keys): if not new_json and not existing_json: @@ -980,7 +1077,7 @@ def _compare_json(new_json, existing_json, excluded_keys): return changed def _format_special_resolution(self, filing): - """For both special resolutions and special resolution corrections.""" + """For special resolutions.""" display_name = FILINGS.get(self._filing.filing_type, {}).get("displayName") if isinstance(display_name, dict): display_name = display_name.get(self._legal_entity.entity_type) @@ -996,22 +1093,23 @@ def _format_special_resolution(self, filing): filing[filing_source]["signingDate"] = signing_date.strftime(OUTPUT_DATE_FORMAT) def _format_special_resolution_application(self, filing, filing_source): - """For both special resolutions and special resolution corrections.""" + """For special resolutions.""" meta_data = self._filing.meta_data or {} - prev_business_name = meta_data.get("changeOfName", {}).get("fromBusinessName") - to_business_name = meta_data.get("changeOfName", {}).get("toBusinessName") - if prev_business_name and to_business_name and prev_business_name != to_business_name: - filing["fromBusinessName"] = prev_business_name - filing["toBusinessName"] = to_business_name - filing["nrNumber"] = filing.get("changeOfName").get("nameRequest", {}).get("nrNumber", None) - prev_association_type = meta_data.get(filing_source, {}).get("fromCooperativeAssociationType") - to_association_type = meta_data.get(filing_source, {}).get("toCooperativeAssociationType") - if prev_association_type and to_association_type and prev_association_type != to_association_type: - filing["prevCoopAssociationType"] = ASSOCIATION_TYPE_DESC.get(prev_association_type, "") - filing["newCoopAssociationType"] = ASSOCIATION_TYPE_DESC.get(to_association_type, "") - filing["rulesInResolution"] = filing.get(filing_source, {}).get("rulesInResolution") - filing["uploadNewRules"] = meta_data.get(filing_source, {}).get("uploadNewRules") - filing["memorandumInResolution"] = filing.get(filing_source, {}).get("memorandumInResolution") + if filing_source == "alteration": + prev_business_name = meta_data.get("changeOfName", {}).get("fromBusinessName") + to_business_name = meta_data.get("changeOfName", {}).get("toBusinessName") + if prev_business_name and to_business_name and prev_business_name != to_business_name: + filing["fromBusinessName"] = prev_business_name + filing["toBusinessName"] = to_business_name + filing["nrNumber"] = filing.get("changeOfName").get("nameRequest", {}).get("nrNumber", None) + elif filing_source == "correction": + prev_business_name = meta_data.get(filing_source, {}).get("fromBusinessName") + to_business_name = meta_data.get(filing_source, {}).get("toBusinessName") + if prev_business_name and to_business_name and prev_business_name != to_business_name: + filing["fromBusinessName"] = prev_business_name + filing["toBusinessName"] = to_business_name + filing["nrNumber"] = filing.get(filing_source).get("nameRequest", {}).get("nrNumber", None) + self._format_resolution_data(filing) def _format_noa_data(self, filing): filing["header"] = {} @@ -1057,6 +1155,14 @@ class ReportMeta: # pylint: disable=too-few-public-methods """Helper class to maintain the report meta information.""" reports = { + "amalgamationApplication": { + "filingDescription": "Amalgamation Application", + "fileName": "amalgamationApplication" + }, + "certificateOfAmalgamation": { + "filingDescription": "Certificate Of Amalgamation", + "fileName": "certificateOfAmalgamation" + }, "certificate": {"filingDescription": "Certificate of Incorporation", "fileName": "certificateOfIncorporation"}, "incorporationApplication": { "filingDescription": "Incorporation Application", @@ -1122,6 +1228,14 @@ class ReportMeta: # pylint: disable=too-few-public-methods }, "restoration": {"filingDescription": "Restoration Application", "fileName": "restoration"}, "letterOfConsent": {"filingDescription": "Letter Of Consent", "fileName": "letterOfConsent"}, + "letterOfAgmExtension": { + "filingDescription": "Letter Of AGM Extension", + "fileName": "letterOfAgmExtension" + }, + "letterOfAgmLocationChange": { + "filingDescription": "Letter Of AGM Location Change", + "fileName": "letterOfAgmLocationChange" + }, } static_reports = { diff --git a/legal-api/src/legal_api/resources/v2/business/business.py b/legal-api/src/legal_api/resources/v2/business/business.py index 757508ceb0..cf1e625447 100644 --- a/legal-api/src/legal_api/resources/v2/business/business.py +++ b/legal-api/src/legal_api/resources/v2/business/business.py @@ -106,7 +106,11 @@ def post_businesses(): if not request.data and not request.is_json: return {"error": babel("No valid JSON submitted.")}, HTTPStatus.BAD_REQUEST json_input = request.get_json() - valid_filing_types = [Filing.FILINGS["incorporationApplication"]["name"], Filing.FILINGS["registration"]["name"]] + valid_filing_types = [ + Filing.FILINGS["incorporationApplication"]["name"], + Filing.FILINGS["registration"]["name"], + Filing.FILINGS["amalgamationApplication"]["name"] + ] try: filing_account_id = json_input["filing"]["header"]["accountId"] @@ -119,9 +123,12 @@ def post_businesses(): # @TODO rollback bootstrap if there is A failure, awaiting changes in the affiliation service bootstrap = RegistrationBootstrapService.create_bootstrap(filing_account_id) if not isinstance(bootstrap, RegistrationBootstrap): - return { - "error": babel("Unable to create {0} Filing.".format(Filing.FILINGS[filing_type]["title"])) - }, HTTPStatus.SERVICE_UNAVAILABLE + if filing_sub_type := Filing.get_filings_sub_type(filing_type, json_input): + title = Filing.FILINGS[filing_type][filing_sub_type]["title"] + else: + title = Filing.FILINGS[filing_type]["title"] + return {"error": babel("Unable to create {0} Filing.".format(title))}, \ + HTTPStatus.SERVICE_UNAVAILABLE try: business_name = json_input["filing"][filing_type]["nameRequest"]["nrNumber"] @@ -152,7 +159,7 @@ def search_businesses(): json_input = request.get_json() identifiers = json_input.get("identifiers", None) if not identifiers or not isinstance(identifiers, list): - return {"message": "Expected a list of 1 or more for '/identifiers'"}, HTTPStatus.BAD_REQUEST + return {"message": "Expected a list of 1 or more for 'identifiers'"}, HTTPStatus.BAD_REQUEST # base business query bus_query = db.session.query(LegalEntity).filter( diff --git a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py index a5541ba324..99d7d4c635 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py +++ b/legal-api/src/legal_api/resources/v2/business/business_digital_credentials.py @@ -1,13 +1,13 @@ # Copyright © 2022 Province of British Columbia # -# Licensed under the Apache License, Version 2.0 (the 'License'); +# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, +# distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. @@ -16,11 +16,14 @@ from datetime import datetime from http import HTTPStatus +import jwt as pyjwt from flask import Blueprint, current_app, jsonify, request from flask_cors import cross_origin -from legal_api.models import DCConnection, DCDefinition, DCIssuedCredential, LegalEntity +from legal_api.decorators import can_access_digital_credentials +from legal_api.models import DCConnection, DCDefinition, DCIssuedCredential, DCRevocationReason, LegalEntity, User from legal_api.services import digital_credentials +from legal_api.services.digital_credentials import DigitalCredentialsHelpers from legal_api.utils.auth import jwt from .bp import bp @@ -31,68 +34,106 @@ @bp.route("//digitalCredentials/invitation", methods=["POST"], strict_slashes=False) @cross_origin(origin="*") @jwt.requires_auth +@can_access_digital_credentials def create_invitation(identifier): """Create a new connection invitation.""" - legal_entity = LegalEntity.find_by_identifier(identifier) - if not legal_entity: + if not (legal_entity := LegalEntity.find_by_identifier(identifier)): return jsonify({"message": f"{identifier} not found."}), HTTPStatus.NOT_FOUND - active_connection = DCConnection.find_active_by(legal_entity_id=legal_entity.id) - if active_connection: + if DCConnection.find_active_by(legal_entity_id=legal_entity.id): return jsonify({"message": f"{identifier} already have an active connection."}), HTTPStatus.UNPROCESSABLE_ENTITY - # check whether this business has an existing connection which is not active - connections = DCConnection.find_by(legal_entity_id=legal_entity.id, connection_state="invitation") - if connections: + if (connections := DCConnection.find_by(legal_entity_id=legal_entity.id, connection_state="invitation")): connection = connections[0] else: - invitation = digital_credentials.create_invitation() - if not invitation: + if not (response := digital_credentials.create_invitation()): return jsonify({"message": "Unable to create an invitation."}), HTTPStatus.INTERNAL_SERVER_ERROR + invitation_message_id = DigitalCredentialsHelpers.extract_invitation_message_id(response) connection = DCConnection( - connection_id=invitation["connection_id"], - invitation_url=invitation["invitation_url"], + connection_id=invitation_message_id, + invitation_url=response["invitation_url"], is_active=False, - connection_state="invitation", - legal_entity_id=legal_entity.id, + connection_state=DCConnection.State.INVITATION.value, + legal_entity_id=legal_entity.id ) connection.save() - return jsonify({"invitationUrl": connection.invitation_url}), HTTPStatus.OK + return jsonify(connection.json), HTTPStatus.OK -@bp.route("//digitalCredentials/connection", methods=["GET", "OPTIONS"], strict_slashes=False) +@bp.route("//digitalCredentials/connections", methods=["GET", "OPTIONS"], strict_slashes=False) @cross_origin(origin="*") @jwt.requires_auth -def get_active_connection(identifier): +@can_access_digital_credentials +def get_connections(identifier): """Get active connection for this LegalEntity.""" - legal_entity = LegalEntity.find_by_identifier(identifier) - if not legal_entity: + if not (legal_entity := LegalEntity.find_by_identifier(identifier)): return jsonify({"message": f"{identifier} not found."}), HTTPStatus.NOT_FOUND - connection = DCConnection.find_active_by(legal_entity_id=legal_entity.id) - if not connection: - return jsonify({"message": "No active connection found."}), HTTPStatus.NOT_FOUND + connections = DCConnection.find_by(legal_entity_id=legal_entity.id) + if len(connections) == 0: + return jsonify({"connections": []}), HTTPStatus.OK - return jsonify(connection.json), HTTPStatus.OK + response = [] + for connection in connections: + response.append(connection.json) + return jsonify({"connections": response}), HTTPStatus.OK + + +@bp.route("//digitalCredentials/connections/", + methods=["DELETE"], strict_slashes=False) +@cross_origin(origin="*") +@jwt.requires_auth +@can_access_digital_credentials +def delete_connection(identifier, connection_id): + """Delete a connection.""" + if not LegalEntity.find_by_identifier(identifier): + return jsonify({"message": f"{identifier} not found."}), HTTPStatus.NOT_FOUND + + if not (connection := DCConnection.find_by_connection_id(connection_id=connection_id)): + return jsonify({"message": f"{identifier} connection not found."}), HTTPStatus.NOT_FOUND + + if (connection.connection_state != DCConnection.State.INVITATION.value and + digital_credentials.remove_connection_record(connection_id=connection.connection_id) is None): + return jsonify({"message": "Failed to remove connection record."}), HTTPStatus.INTERNAL_SERVER_ERROR + + connection.delete() + return jsonify({"message": "Connection has been deleted."}), HTTPStatus.OK + + +@bp.route("//digitalCredentials/activeConnection", methods=["DELETE"], strict_slashes=False) +@cross_origin(origin="*") +@jwt.requires_auth +@can_access_digital_credentials +def delete_active_connection(identifier): + """Delete an active connection for this LegalEntity.""" + if not (legal_entity := LegalEntity.find_by_identifier(identifier)): + return jsonify({"message": f"{identifier} not found."}), HTTPStatus.NOT_FOUND + + if not (connection := DCConnection.find_active_by(legal_entity_id=legal_entity.id)): + return jsonify({"message": f"{identifier} active connection not found."}), HTTPStatus.NOT_FOUND + + if digital_credentials.remove_connection_record(connection_id=connection.connection_id) is None: + return jsonify({"message": "Failed to remove connection record."}), HTTPStatus.INTERNAL_SERVER_ERROR + + connection.delete() + return jsonify({"message": "Connection has been deleted."}), HTTPStatus.OK @bp.route("//digitalCredentials", methods=["GET", "OPTIONS"], strict_slashes=False) @cross_origin(origin="*") @jwt.requires_auth +@can_access_digital_credentials def get_issued_credentials(identifier): """Get all issued credentials.""" - legal_entity = LegalEntity.find_by_identifier(identifier) - if not legal_entity: + if not (legal_entity := LegalEntity.find_by_identifier(identifier)): return jsonify({"message": f"{identifier} not found."}), HTTPStatus.NOT_FOUND - connection = DCConnection.find_active_by(legal_entity_id=legal_entity.id) - if not connection: + if not (connection := DCConnection.find_active_by(legal_entity_id=legal_entity.id)): return jsonify({"issuedCredentials": []}), HTTPStatus.OK - issued_credentials = DCIssuedCredential.find_by(dc_connection_id=connection.id) - if not issued_credentials: + if not (issued_credentials := DCIssuedCredential.find_by(dc_connection_id=connection.id)): return jsonify({"issuedCredentials": []}), HTTPStatus.OK response = [] @@ -102,6 +143,7 @@ def get_issued_credentials(identifier): { "legalName": legal_entity.legal_name, "credentialType": definition.credential_type.name, + "credentialId": issued_credential.credential_id, "isIssued": issued_credential.is_issued, "dateOfIssue": issued_credential.date_of_issue.isoformat() if issued_credential.date_of_issue else "", "isRevoked": issued_credential.is_revoked, @@ -113,69 +155,127 @@ def get_issued_credentials(identifier): @bp.route("//digitalCredentials/", methods=["POST"], strict_slashes=False) @cross_origin(origin="*") @jwt.requires_auth +@can_access_digital_credentials def send_credential(identifier, credential_type): """Issue credentials to the connection.""" - legal_entity = LegalEntity.find_by_identifier(identifier) - if not legal_entity: + if not (token := pyjwt.decode(jwt.get_token_auth_header(), options={"verify_signature": False})): + return jsonify({"message": "Unable to decode JWT"}, HTTPStatus.UNAUTHORIZED) + + if not (legal_entity := LegalEntity.find_by_identifier(identifier)): return jsonify({"message": f"{identifier} not found"}), HTTPStatus.NOT_FOUND + if not (user := User.find_by_jwt_token(token)): + return jsonify({"message": "User not found"}, HTTPStatus.NOT_FOUND) + connection = DCConnection.find_active_by(legal_entity_id=legal_entity.id) - definition = DCDefinition.find_by_credential_type(DCDefinition.CredentialType[credential_type]) + definition = DCDefinition.find_by(DCDefinition.CredentialType[credential_type], + digital_credentials.business_schema_id, + digital_credentials.business_cred_def_id) issued_credentials = DCIssuedCredential.find_by(dc_connection_id=connection.id, dc_definition_id=definition.id) if issued_credentials and issued_credentials[0].credential_exchange_id: return jsonify({"message": "Already requested to issue credential."}), HTTPStatus.INTERNAL_SERVER_ERROR - response = digital_credentials.issue_credential( + credential_data = DigitalCredentialsHelpers.get_digital_credential_data(legal_entity, + user, + definition.credential_type) + credential_id = next((item["value"] for item in credential_data if item["name"] == "credential_id"), None) + + if not (response := digital_credentials.issue_credential( connection_id=connection.connection_id, definition=definition, - data=_get_data_for_credential(definition.credential_type, legal_entity), - ) - if not response: + data=credential_data + )): return jsonify({"message": "Failed to issue credential."}), HTTPStatus.INTERNAL_SERVER_ERROR issued_credential = DCIssuedCredential( dc_definition_id=definition.id, dc_connection_id=connection.id, - credential_exchange_id=response["credential_exchange_id"], + credential_exchange_id=response["cred_ex_id"], + credential_id=credential_id ) issued_credential.save() - return jsonify({"message": "Issue Credential is initiated."}), HTTPStatus.OK + return jsonify(issued_credential.json), HTTPStatus.OK -def _get_data_for_credential(credential_type: DCDefinition.CredentialType, legal_entity: LegalEntity): - if credential_type == DCDefinition.CredentialType.business: - return [ - {"name": "legalName", "value": legal_entity.legal_name}, - {"name": "foundingDate", "value": legal_entity.founding_date.isoformat()}, - {"name": "taxId", "value": legal_entity.tax_id or ""}, - {"name": "homeJurisdiction", "value": "BC"}, # for corp types that are not -xpro, the jurisdiction is BC - {"name": "legalType", "value": legal_entity.entity_type}, - {"name": "identifier", "value": legal_entity.identifier}, - ] +@bp.route("//digitalCredentials//revoke", + methods=["POST"], strict_slashes=False) +@cross_origin(origin="*") +@jwt.requires_auth +@can_access_digital_credentials +def revoke_credential(identifier, credential_id): + """Revoke a credential.""" + if not (legal_entity := LegalEntity.find_by_identifier(identifier)): + return jsonify({"message": f"{identifier} not found."}), HTTPStatus.NOT_FOUND - return None + if not (connection := DCConnection.find_active_by(legal_entity_id=legal_entity.id)): + return jsonify({"message": f"{identifier} active connection not found."}), HTTPStatus.NOT_FOUND + issued_credential = DCIssuedCredential.find_by_credential_id(credential_id=credential_id) + if not issued_credential or issued_credential.is_revoked: + return jsonify({"message": f"{identifier} issued credential not found."}), HTTPStatus.NOT_FOUND -@bp_dc.route("/topic/", methods=["POST"], strict_slashes=False) + reissue = request.get_json().get("reissue", False) + reason = DCRevocationReason.SELF_REISSUANCE if reissue else DCRevocationReason.SELF_REVOCATION + + if digital_credentials.revoke_credential(connection.connection_id, + issued_credential.credential_revocation_id, + issued_credential.revocation_registry_id, + reason) is None: + return jsonify({"message": "Failed to revoke credential."}), HTTPStatus.INTERNAL_SERVER_ERROR + + issued_credential.is_revoked = True + issued_credential.save() + return jsonify({"message": "Credential has been revoked."}), HTTPStatus.OK + + +@bp.route("//digitalCredentials/", methods=["DELETE"], strict_slashes=False) @cross_origin(origin="*") @jwt.requires_auth +@can_access_digital_credentials +def delete_credential(identifier, credential_id): + """Delete a credential.""" + if not LegalEntity.find_by_identifier(identifier): + return jsonify({"message": f"{identifier} not found."}), HTTPStatus.NOT_FOUND + + if not (issued_credential := DCIssuedCredential.find_by_credential_id(credential_id=credential_id)): + return jsonify({"message": f"{identifier} issued credential not found."}), HTTPStatus.NOT_FOUND + + if (digital_credentials.fetch_credential_exchange_record(issued_credential.credential_exchange_id) is not None and + digital_credentials.remove_credential_exchange_record(issued_credential.credential_exchange_id) is None): + return jsonify({"message": "Failed to remove credential exchange record."}), HTTPStatus.INTERNAL_SERVER_ERROR + + issued_credential.delete() + return jsonify({"message": "Credential has been deleted."}), HTTPStatus.OK + + +@bp_dc.route("/topic/", methods=["POST"], strict_slashes=False) +@cross_origin(origin="*") def webhook_notification(topic_name: str): """To receive notification from aca-py admin api.""" json_input = request.get_json() try: if topic_name == "connections": - connection = DCConnection.find_by_connection_id(json_input["connection_id"]) - # Trinsic Wallet will send `active` only when it’s used the first time. - # Looking for `response` state to handle it. - if connection and not connection.is_active and json_input["state"] in ("response", "active"): - connection.connection_state = "active" + connection = DCConnection.find_by_connection_id( + DigitalCredentialsHelpers.extract_invitation_message_id(json_input)) + # Using https://didcomm.org/connections/1.0 protocol the final state is "active" + # Using https://didcomm.org/didexchange/1.0 protocol the final state is "completed" + if connection and not connection.is_active and json_input["state"] in ( + DCConnection.State.ACTIVE.value, DCConnection.State.COMPLETED.value): + connection.connection_id = json_input["connection_id"] + connection.connection_state = json_input["state"] connection.is_active = True connection.save() - elif topic_name == "issue_credential": - issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input["credential_exchange_id"]) - if issued_credential and json_input["state"] == "credential_issued": + elif topic_name == "issuer_cred_rev": + issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input["cred_ex_id"]) + if issued_credential and json_input["state"] == "issued": + issued_credential.credential_revocation_id = json_input["cred_rev_id"] + issued_credential.revocation_registry_id = json_input["rev_reg_id"] + issued_credential.save() + elif topic_name == "issue_credential_v2_0": + issued_credential = DCIssuedCredential.find_by_credential_exchange_id(json_input["cred_ex_id"]) + if issued_credential and json_input["state"] == "done": issued_credential.date_of_issue = datetime.utcnow() issued_credential.is_issued = True issued_credential.save() diff --git a/legal-api/src/legal_api/resources/v2/business/business_directors.py b/legal-api/src/legal_api/resources/v2/business/business_directors.py index baf69b03d8..ab5608ccfe 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_directors.py +++ b/legal-api/src/legal_api/resources/v2/business/business_directors.py @@ -59,8 +59,6 @@ def get_directors(identifier, director_id=None): active_directors = EntityRole.get_active_directors(legal_entity.id, end_date) for director in active_directors: director_json = director.json - if legal_entity.entity_type == LegalEntity.EntityTypes.COOP.value: - del director_json["mailingAddress"] party_list.append(director_json) return jsonify(directors=party_list) diff --git a/legal-api/src/legal_api/resources/v2/business/business_filings/business_filings.py b/legal-api/src/legal_api/resources/v2/business/business_filings/business_filings.py index 3f7d2ca8cb..26150402ac 100644 --- a/legal-api/src/legal_api/resources/v2/business/business_filings/business_filings.py +++ b/legal-api/src/legal_api/resources/v2/business/business_filings/business_filings.py @@ -140,18 +140,16 @@ def saving_filings( # pylint: disable=too-many-return-statements,too-many-local return response, response_code # get header params - payment_account_id = request.headers.get("accountId", None) + payment_account_id = request.headers.get("account-id", request.headers.get("accountId", None)) - if ( - not query.draft - and not ListFilingResource.is_historical_colin_filing(json_input) - and not ListFilingResource.is_before_epoch_filing(json_input, legal_entity) - ): + if not query.draft \ + and not ListFilingResource.is_historical_colin_filing(json_input) \ + and not ListFilingResource.is_before_epoch_filing(json_input, legal_entity): if identifier.startswith("T"): business_validate = RegistrationBootstrap.find_by_identifier(identifier) else: business_validate = legal_entity - err = validate(business_validate, json_input) + err = validate(business_validate, json_input, payment_account_id) if err or query.only_validate: if err: json_input["errors"] = err.msg @@ -401,6 +399,7 @@ def check_and_update_nr(filing): if filing.filing_type in ( Filing.FILINGS["incorporationApplication"]["name"], Filing.FILINGS["registration"]["name"], + Filing.FILINGS["amalgamationApplication"]["name"], ): nr_number = filing.json["filing"][filing.filing_type]["nameRequest"].get("nrNumber", None) effective_date = filing.json["filing"]["header"].get("effectiveDate", None) @@ -460,11 +459,11 @@ def put_basic_checks(identifier, filing_id, client_request, legal_entity) -> Tup if not filing_type: return ({"message": "filing/header/name is a required property"}, HTTPStatus.BAD_REQUEST) - if ( - filing_type - not in [Filing.FILINGS["incorporationApplication"]["name"], Filing.FILINGS["registration"]["name"]] - and legal_entity is None - ): + if filing_type not in [ + Filing.FILINGS["incorporationApplication"]["name"], + Filing.FILINGS["registration"]["name"], + Filing.FILINGS["amalgamationApplication"]["name"] + ] and legal_entity is None: return ({"message": "A valid business is required."}, HTTPStatus.BAD_REQUEST) return None, None @@ -697,7 +696,11 @@ def get_filing_types(legal_entity: LegalEntity, filing_json: dict): # pylint: d filing_type = filing_json["filing"]["header"].get("name", None) waive_fees_flag = filing_json["filing"]["header"].get("waiveFees", False) - if filing_type in (Filing.FILINGS["incorporationApplication"]["name"], Filing.FILINGS["registration"]["name"]): + if filing_type in ( + Filing.FILINGS["incorporationApplication"]["name"], + Filing.FILINGS["registration"]["name"], + Filing.FILINGS["amalgamationApplication"]["name"] + ): entity_type = filing_json["filing"][filing_type]["nameRequest"]["legalType"] else: entity_type = legal_entity.entity_type @@ -780,9 +783,19 @@ def get_filing_types(legal_entity: LegalEntity, filing_json: dict): # pylint: d } ) elif filing_type_code: - filing_types.append( - {"filingTypeCode": filing_type_code, "priority": priority, "waiveFees": waive_fees_flag} - ) + if k == "alteration": + filing_types.append({ + "filingTypeCode": filing_type_code, + "futureEffective": ListFilingResource.is_future_effective_filing(filing_json), + "priority": priority, + "waiveFees": waive_fees_flag + }) + else: + filing_types.append({ + "filingTypeCode": filing_type_code, + "priority": priority, + "waiveFees": waive_fees_flag + }) return filing_types # pylint: disable=too-many-locals,too-many-branches,too-many-statements @@ -808,11 +821,12 @@ def create_invoice( if filing.filing_type in ( Filing.FILINGS["incorporationApplication"]["name"], Filing.FILINGS["registration"]["name"], + Filing.FILINGS["amalgamationApplication"]["name"] ): - if filing.filing_type == Filing.FILINGS["incorporationApplication"]["name"]: + if filing.filing_type in [Filing.FILINGS["incorporationApplication"]["name"], + Filing.FILINGS["amalgamationApplication"]["name"]]: mailing_address = Address.create_address( - filing.json["filing"]["incorporationApplication"]["offices"]["registeredOffice"]["mailingAddress"] - ) + filing.json["filing"][filing.filing_type]["offices"]["registeredOffice"]["mailingAddress"]) elif filing.filing_type == Filing.FILINGS["registration"]["name"]: mailing_address = Address.create_address( filing.json["filing"]["registration"]["offices"]["businessOffice"]["mailingAddress"] @@ -924,7 +938,11 @@ def create_invoice( def set_effective_date(legal_entity: LegalEntity, filing: Filing): """Set the effective date of the Filing.""" filing_type = filing.filing_json["filing"]["header"]["name"] - if filing_type in (Filing.FILINGS["incorporationApplication"]["name"], Filing.FILINGS["registration"]["name"]): + if filing_type in ( + Filing.FILINGS["incorporationApplication"]["name"], + Filing.FILINGS["registration"]["name"], + Filing.FILINGS["amalgamationApplication"]["name"] + ): if fe_date := filing.filing_json["filing"]["header"].get("futureEffectiveDate"): filing.effective_date = datetime.datetime.fromisoformat(fe_date) filing.save() diff --git a/legal-api/src/legal_api/resources/v2/namerequest.py b/legal-api/src/legal_api/resources/v2/namerequest.py index 4d3e1fa4b9..223b6849b0 100644 --- a/legal-api/src/legal_api/resources/v2/namerequest.py +++ b/legal-api/src/legal_api/resources/v2/namerequest.py @@ -15,17 +15,18 @@ Provides a proxy endpoint to retrieve name request data. """ -from flask import Blueprint, abort, current_app, jsonify, make_response +from flask import Blueprint, abort, current_app, jsonify, make_response, request from flask_cors import cross_origin from legal_api.services import namex +from legal_api.services.bootstrap import AccountService bp = Blueprint("NAMEREQUEST2", __name__, url_prefix="/api/v2/nameRequests") -@bp.route("/", methods=["GET"]) +@bp.route("//validate", methods=["GET"]) @cross_origin(origin="*") -def get(identifier): +def validate_with_contact_info(identifier): """Return a JSON object with name request information.""" try: nr_response = namex.query_nr_number(identifier) @@ -34,7 +35,26 @@ def get(identifier): if nr_response.status_code == 404: return make_response(jsonify(message="{} not found.".format(identifier)), 404) - return jsonify(nr_response.json()) + nr_json = nr_response.json() + + # Check the NR is affiliated with this account + orgs_response = AccountService.get_account_by_affiliated_identifier(identifier) + if len(orgs_response["orgs"]): + return jsonify(nr_json) + + # The request must include email or phone number + email = request.args.get("email", None) + phone = request.args.get("phone", None) + if not (email or phone): + return make_response(jsonify(message="The request must include email or phone number."), 403) + + # If NR is not affiliated, validate the email and phone + nr_phone = nr_json.get("applicants").get("phoneNumber") + nr_email = nr_json.get("applicants").get("emailAddress") + if (phone and phone != nr_phone) or (email and email != nr_email): + return make_response(jsonify(message="Invalid email or phone number."), 400) + + return jsonify(nr_json) except Exception as err: current_app.logger.error(err) abort(500) diff --git a/legal-api/src/legal_api/services/authz.py b/legal-api/src/legal_api/services/authz.py index 8a8626eed2..ee2104fe4c 100644 --- a/legal-api/src/legal_api/services/authz.py +++ b/legal-api/src/legal_api/services/authz.py @@ -12,11 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. """This manages all of the authentication and authorization service.""" +from datetime import datetime, timezone from enum import Enum from http import HTTPStatus -from typing import Final, List +from typing import List from urllib.parse import urljoin +import jwt as pyjwt import requests from flask import current_app from flask_jwt_oidc import JwtManager @@ -25,7 +27,7 @@ from urllib3.util.retry import Retry from legal_api.exceptions import ApiConnectionException -from legal_api.models import Filing, LegalEntity +from legal_api.models import EntityRole, Filing, LegalEntity, User from legal_api.services.warnings.business.business_checks import WarningType ACCOUNT_IDENTITY = "account_identity" @@ -54,6 +56,14 @@ class BusinessBlocker(str, Enum): NOT_IN_GOOD_STANDING = "NOT_IN_GOOD_STANDING" +class BusinessRequirement(str, Enum): + """Define an enum for business requirement scenarios.""" + + EXIST = 'EXIST' + NOT_EXIST = 'NOT_EXIST' + NO_RESTRICTION = 'NO_RESTRICTION' + + def authorized( # pylint: disable=too-many-return-statements identifier: str, jwt: JwtManager, action: List[str] ) -> bool: @@ -115,182 +125,272 @@ def has_roles(jwt: JwtManager, roles: List[str]) -> bool: return False -ALLOWABLE_FILINGS: Final = { - "staff": { - LegalEntity.State.ACTIVE: { - "adminFreeze": { - "legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"], - }, - "alteration": { - "legalTypes": ["BC", "BEN", "ULC", "CC"], - "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, - }, - "annualReport": { - "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], - "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, - }, - "changeOfAddress": { - "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], - "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, - }, - "changeOfDirectors": { - "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], - "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, - }, - "changeOfRegistration": { - "legalTypes": ["SP", "GP"], - "blockerChecks": { - "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], - "business": [BusinessBlocker.DEFAULT], - }, - }, - "consentContinuationOut": { - "legalTypes": ["BC", "BEN", "CC", "ULC"], - "blockerChecks": {"business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING]}, - }, - "continuationOut": { - "legalTypes": ["BC", "BEN", "CC", "ULC"], - "blockerChecks": { - "business": [BusinessBlocker.NOT_IN_GOOD_STANDING], - "completedFilings": ["consentContinuationOut"], +def get_allowable_filings_dict(): + """Return dictionary containing rules for when filings are allowed.""" + # importing here to avoid circular dependencies + # pylint: disable=import-outside-toplevel + from legal_api.core.filing import Filing as CoreFiling + + filing_types_compact = CoreFiling.FilingTypesCompact + + return { + "staff": { + LegalEntity.State.ACTIVE: { + "adminFreeze": { + "legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"], }, - }, - "conversion": {"legalTypes": ["SP", "GP"]}, - "correction": { - "legalTypes": ["CP", "BEN", "SP", "GP", "BC", "ULC", "CC"], - "blockerChecks": { - "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], - "business": [BusinessBlocker.DEFAULT], + "agmExtension": { + "legalTypes": ["BC", "BEN", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING] + } }, - }, - "courtOrder": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, - "dissolution": { - "voluntary": { - "legalTypes": ["CP", "BC", "BEN", "CC", "ULC", "SP", "GP"], + "agmLocationChange": { + "legalTypes": ["BC", "BEN", "ULC", "CC"], "blockerChecks": { - "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], - "business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING], + "business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING] + } + }, + "alteration": { + "legalTypes": ["BC", "BEN", "ULC", "CC"], + "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, + }, + "amalgamationApplication": { + "businessRequirement": BusinessRequirement.NO_RESTRICTION, + "regular": { + "legalTypes": ["BEN", "BC", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.BUSINESS_FROZEN], + "futureEffectiveFilings": [filing_types_compact.DISSOLUTION_VOLUNTARY, + filing_types_compact.DISSOLUTION_ADMINISTRATIVE] + } }, + "vertical": { + "legalTypes": ["BEN", "BC", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.BUSINESS_FROZEN], + "futureEffectiveFilings": [filing_types_compact.DISSOLUTION_VOLUNTARY, + filing_types_compact.DISSOLUTION_ADMINISTRATIVE] + } + }, + "horizontal": { + "legalTypes": ["BEN", "BC", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.BUSINESS_FROZEN], + "futureEffectiveFilings": [filing_types_compact.DISSOLUTION_VOLUNTARY, + filing_types_compact.DISSOLUTION_ADMINISTRATIVE] + } + } + }, + "annualReport": { + "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], + "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, }, - "administrative": { - "legalTypes": ["CP", "BC", "BEN", "CC", "ULC", "SP", "GP"], + "changeOfAddress": { + "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], + "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, + }, + "changeOfDirectors": { + "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], + "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, + }, + "changeOfRegistration": { + "legalTypes": ["SP", "GP"], "blockerChecks": { "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], - "business": [BusinessBlocker.DRAFT_PENDING], + "business": [BusinessBlocker.DEFAULT], }, }, - }, - "incorporationApplication": { - "legalTypes": ["CP", "BC", "BEN", "ULC", "CC"], - "businessExists": False, # only show filing when providing allowable filings not specific to a business - }, - "registrarsNotation": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, - "registrarsOrder": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, - "registration": { - "legalTypes": ["SP", "GP"], - "businessExists": False, # only show filing when providing allowable filings not specific to a business - }, - "specialResolution": {"legalTypes": ["CP"], "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}}, - "transition": {"legalTypes": ["BC", "BEN", "CC", "ULC"]}, - "restoration": { - "limitedRestorationExtension": { + "consentContinuationOut": { + "legalTypes": ["BC", "BEN", "CC", "ULC"], + "blockerChecks": {"business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING]}, + }, + "continuationOut": { "legalTypes": ["BC", "BEN", "CC", "ULC"], "blockerChecks": { - "validStateFilings": [ - "restoration.limitedRestoration", - "restoration.limitedRestorationExtension", - ], - "business": [BusinessBlocker.DEFAULT], + "business": [BusinessBlocker.NOT_IN_GOOD_STANDING], + "completedFilings": ["consentContinuationOut"], }, }, - "limitedRestorationToFull": { - "legalTypes": ["BC", "BEN", "CC", "ULC"], + "conversion": {"legalTypes": ["SP", "GP"]}, + "correction": { + "legalTypes": ["CP", "BEN", "SP", "GP", "BC", "ULC", "CC"], "blockerChecks": { - "validStateFilings": [ - "restoration.limitedRestoration", - "restoration.limitedRestorationExtension", - ], + "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], "business": [BusinessBlocker.DEFAULT], }, }, + "courtOrder": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, + "dissolution": { + "voluntary": { + "legalTypes": ["CP", "BC", "BEN", "CC", "ULC", "SP", "GP"], + "blockerChecks": { + "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], + "business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING], + }, + }, + "administrative": { + "legalTypes": ["CP", "BC", "BEN", "CC", "ULC", "SP", "GP"], + "blockerChecks": { + "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], + "business": [BusinessBlocker.DRAFT_PENDING], + }, + }, + }, + "incorporationApplication": { + "legalTypes": ["CP", "BC", "BEN", "ULC", "CC"], + # only show filing when providing allowable filings not specific to a business + "businessRequirement": BusinessRequirement.NOT_EXIST + }, + "registrarsNotation": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, + "registrarsOrder": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, + "registration": { + "legalTypes": ["SP", "GP"], + # only show filing when providing allowable filings not specific to a business + "businessRequirement": BusinessRequirement.NOT_EXIST + }, + "specialResolution": {"legalTypes": ["CP"], "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}}, + "transition": {"legalTypes": ["BC", "BEN", "CC", "ULC"]}, + "restoration": { + "limitedRestorationExtension": { + "legalTypes": ["BC", "BEN", "CC", "ULC"], + "blockerChecks": { + "validStateFilings": [ + filing_types_compact.RESTORATION_LIMITED_RESTORATION, + filing_types_compact.RESTORATION_LIMITED_RESTORATION_EXT, + ], + "business": [BusinessBlocker.DEFAULT], + }, + }, + "limitedRestorationToFull": { + "legalTypes": ["BC", "BEN", "CC", "ULC"], + "blockerChecks": { + "validStateFilings": [ + filing_types_compact.RESTORATION_LIMITED_RESTORATION, + filing_types_compact.RESTORATION_LIMITED_RESTORATION_EXT, + ], + "business": [BusinessBlocker.DEFAULT], + }, + }, + }, }, - }, - LegalEntity.State.HISTORICAL: { - "courtOrder": { - "legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"], - }, - "putBackOn": { - "legalTypes": ["SP", "GP", "BEN", "CP", "BC", "CC", "ULC"], - "blockerChecks": {"validStateFilings": ["dissolution.administrative"]}, - }, - "registrarsNotation": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, - "registrarsOrder": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, - "restoration": { - "fullRestoration": { - "legalTypes": ["BC", "BEN", "CC", "ULC"], - "blockerChecks": {"invalidStateFilings": ["continuationIn", "continuationOut"]}, + LegalEntity.State.HISTORICAL: { + "courtOrder": { + "legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"], }, - "limitedRestoration": { - "legalTypes": ["BC", "BEN", "CC", "ULC"], - "blockerChecks": {"invalidStateFilings": ["continuationIn", "continuationOut"]}, + "putBackOn": { + "legalTypes": ["SP", "GP", "BEN", "CP", "BC", "CC", "ULC"], + "blockerChecks": {"validStateFilings": [filing_types_compact.DISSOLUTION_ADMINISTRATIVE]}, + }, + "registrarsNotation": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, + "registrarsOrder": {"legalTypes": ["SP", "GP", "CP", "BC", "BEN", "CC", "ULC"]}, + "restoration": { + "fullRestoration": { + "legalTypes": ["BC", "BEN", "CC", "ULC"], + "blockerChecks": {"invalidStateFilings": ["continuationIn", "continuationOut"]}, + }, + "limitedRestoration": { + "legalTypes": ["BC", "BEN", "CC", "ULC"], + "blockerChecks": {"invalidStateFilings": ["continuationIn", "continuationOut"]}, + }, }, }, }, - }, - "general": { - LegalEntity.State.ACTIVE: { - "alteration": { - "legalTypes": ["BC", "BEN", "ULC", "CC"], - "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, - }, - "annualReport": { - "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], - "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, - }, - "changeOfAddress": { - "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], - "blockerChecks": { - "business": [BusinessBlocker.DEFAULT], + "general": { + LegalEntity.State.ACTIVE: { + "agmExtension": { + "legalTypes": ["BC", "BEN", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING] + } }, - }, - "changeOfDirectors": { - "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], - "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, - }, - "changeOfRegistration": { - "legalTypes": ["SP", "GP"], - "blockerChecks": { - "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], - "business": [BusinessBlocker.DEFAULT], + "agmLocationChange": { + "legalTypes": ["BC", "BEN", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING] + } }, - }, - "consentContinuationOut": { - "legalTypes": ["BC", "BEN", "CC", "ULC"], - "blockerChecks": {"business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING]}, - }, - "dissolution": { - "voluntary": { - "legalTypes": ["CP", "BC", "BEN", "CC", "ULC", "SP", "GP"], + "alteration": { + "legalTypes": ["BC", "BEN", "ULC", "CC"], + "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, + }, + "amalgamationApplication": { + "businessRequirement": BusinessRequirement.NO_RESTRICTION, + "regular": { + "legalTypes": ["BEN", "BC", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.BUSINESS_FROZEN], + "futureEffectiveFilings": [filing_types_compact.DISSOLUTION_VOLUNTARY, + filing_types_compact.DISSOLUTION_ADMINISTRATIVE] + } + }, + "vertical": { + "legalTypes": ["BEN", "BC", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.BUSINESS_FROZEN], + "futureEffectiveFilings": [filing_types_compact.DISSOLUTION_VOLUNTARY, + filing_types_compact.DISSOLUTION_ADMINISTRATIVE] + } + }, + "horizontal": { + "legalTypes": ["BEN", "BC", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.BUSINESS_FROZEN], + "futureEffectiveFilings": [filing_types_compact.DISSOLUTION_VOLUNTARY, + filing_types_compact.DISSOLUTION_ADMINISTRATIVE] + } + } + }, + "annualReport": { + "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], + "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, + }, + "changeOfAddress": { + "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], + "blockerChecks": { + "business": [BusinessBlocker.DEFAULT], + }, + }, + "changeOfDirectors": { + "legalTypes": ["CP", "BEN", "BC", "ULC", "CC"], + "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}, + }, + "changeOfRegistration": { + "legalTypes": ["SP", "GP"], "blockerChecks": { "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], - "business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING], + "business": [BusinessBlocker.DEFAULT], }, }, + "consentContinuationOut": { + "legalTypes": ["BC", "BEN", "CC", "ULC"], + "blockerChecks": {"business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING]}, + }, + "dissolution": { + "voluntary": { + "legalTypes": ["CP", "BC", "BEN", "CC", "ULC", "SP", "GP"], + "blockerChecks": { + "warningTypes": [WarningType.MISSING_REQUIRED_BUSINESS_INFO], + "business": [BusinessBlocker.DEFAULT, BusinessBlocker.NOT_IN_GOOD_STANDING], + }, + }, + }, + "incorporationApplication": { + "legalTypes": ["CP", "BC", "BEN", "ULC", "CC"], + # only show filing when providing allowable filings not specific to a business + "businessRequirement": BusinessRequirement.NOT_EXIST + }, + "registration": { + "legalTypes": ["SP", "GP"], + # only show filing when providing allowable filings not specific to a business + "businessRequirement": BusinessRequirement.NOT_EXIST + }, + "specialResolution": {"legalTypes": ["CP"], "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}}, + "transition": {"legalTypes": ["BC", "BEN", "CC", "ULC"]}, }, - "incorporationApplication": { - "legalTypes": ["CP", "BC", "BEN", "ULC", "CC"], - "businessExists": False, # only show filing when providing allowable filings not specific to a business - }, - "registration": { - "legalTypes": ["SP", "GP"], - "businessExists": False, # only show filing when providing allowable filings not specific to a business - }, - "specialResolution": {"legalTypes": ["CP"], "blockerChecks": {"business": [BusinessBlocker.DEFAULT]}}, - "transition": {"legalTypes": ["BC", "BEN", "CC", "ULC"]}, + LegalEntity.State.HISTORICAL: {}, }, - LegalEntity.State.HISTORICAL: {}, - }, -} + } # pylint: disable=(too-many-arguments,too-many-locals @@ -326,7 +426,13 @@ def get_allowable_actions(jwt: JwtManager, legal_entity: LegalEntity): base_url = current_app.config.get("LEGAL_API_BASE_URL") allowed_filings = get_allowed_filings(legal_entity, legal_entity.state, legal_entity.entity_type, jwt) filing_submission_url = urljoin(base_url, f"{legal_entity.identifier}/filings") - result = {"filing": {"filingSubmissionLink": filing_submission_url, "filingTypes": allowed_filings}} + result = { + "filing": { + "filingSubmissionLink": filing_submission_url, + "filingTypes": allowed_filings + }, + "digitalBusinessCard": are_digital_credentials_allowed(legal_entity, jwt) + } return result @@ -352,13 +458,17 @@ def get_allowed_filings( # doing this check up front to cache result business_blocker_dict: dict = business_blocker_check(legal_entity, is_ignore_draft_blockers) - allowable_filings = ALLOWABLE_FILINGS.get(user_role, {}).get(state, {}) + allowable_filings = get_allowable_filings_dict().get(user_role, {}).get(state, {}) allowable_filing_types = [] for allowable_filing_key, allowable_filing_value in allowable_filings.items(): # skip if business does not exist and filing is not required # skip if this filing does not need to be returned for existing businesses - if bool(legal_entity) ^ allowable_filing_value.get("businessExists", True): + + business_status = allowable_filing_value.get('businessRequirement', BusinessRequirement.EXIST) + + if business_status != BusinessRequirement.NO_RESTRICTION and \ + bool(legal_entity) ^ (business_status == BusinessRequirement.EXIST): continue allowable_filing_legal_types = allowable_filing_value.get("legalTypes", []) @@ -377,28 +487,30 @@ def get_allowed_filings( ) continue - filing_sub_type_items = filter( - lambda x: legal_type in x[1].get("legalTypes", []), allowable_filing_value.items() - ) + filing_sub_type_items = \ + filter(lambda x: isinstance(x[1], dict) and legal_type in + x[1].get("legalTypes", []), allowable_filing_value.items()) + for filing_sub_type_item_key, filing_sub_type_item_value in filing_sub_type_items: - is_allowable = not has_blocker( - legal_entity, state_filing, filing_sub_type_item_value, business_blocker_dict - ) + is_allowable = not has_blocker(legal_entity, state_filing, filing_sub_type_item_value, business_blocker_dict) allowable_filing_sub_type = { "name": allowable_filing_key, "type": filing_sub_type_item_key, "displayName": FilingMeta.get_display_name(legal_type, allowable_filing_key, filing_sub_type_item_key), "feeCode": Filing.get_fee_code(legal_type, allowable_filing_key, filing_sub_type_item_key), } - allowable_filing_types = add_allowable_filing_type( - is_allowable, allowable_filing_types, allowable_filing_sub_type - ) + allowable_filing_types = add_allowable_filing_type(is_allowable, + allowable_filing_types, + allowable_filing_sub_type) return allowable_filing_types def has_blocker(legal_entity: LegalEntity, state_filing: Filing, allowable_filing: dict, business_blocker_dict: dict): """Return True if allowable filing has a blocker.""" + if not legal_entity: + return False + if not (blocker_checks := allowable_filing.get("blockerChecks", {})): return False @@ -414,6 +526,9 @@ def has_blocker(legal_entity: LegalEntity, state_filing: Filing, allowable_filin if has_blocker_completed_filing(legal_entity, blocker_checks): return True + if has_blocker_future_effective_filing(legal_entity, blocker_checks): + return True + if has_blocker_warning_filing(legal_entity.warnings, blocker_checks): return True @@ -520,6 +635,24 @@ def has_blocker_completed_filing(legal_entity: LegalEntity, blocker_checks: dict return True +def has_blocker_future_effective_filing(legal_entity: LegalEntity, blocker_checks: dict): + """Check if business has a future effective filing.""" + if not (fed_filing_types := blocker_checks.get('futureEffectiveFilings', [])): + return False + + filing_type_pairs = [(parse_filing_info(x)) for x in fed_filing_types] + + pending_filings = Filing.get_filings_by_type_pairs(legal_entity.id, + filing_type_pairs, + [Filing.Status.PENDING.value, Filing.Status.PAID.value], + True) + + now = datetime.utcnow().replace(tzinfo=timezone.utc) + is_fed = any(f.effective_date > now for f in pending_filings) + + return is_fed + + def has_filing_match(filing: Filing, filing_types: list): """Return if filing matches any filings provided in filing_types arg .""" for filing_type in filing_types: @@ -564,14 +697,15 @@ def get_allowed(state: LegalEntity.State, legal_type: str, jwt: JwtManager): if jwt.contains_role([STAFF_ROLE, SYSTEM_ROLE, COLIN_SVC_ROLE]): user_role = "staff" - allowable_filings = ALLOWABLE_FILINGS.get(user_role, {}).get(state, {}) + allowable_filings = get_allowable_filings_dict().get(user_role, {}).get(state, {}) allowable_filing_types = [] for allowable_filing_key, allowable_filing_value in allowable_filings.items(): if legal_types := allowable_filing_value.get("legalTypes", None): if legal_type in legal_types: allowable_filing_types.append(allowable_filing_key) else: - sub_filing_types = [x for x in allowable_filing_value.items() if legal_type in x[1].get("legalTypes")] + sub_filing_types = [x for x in allowable_filing_value.items() + if isinstance(x[1], dict) and legal_type in x[1].get("legalTypes")] if sub_filing_types: allowable_filing_types.append( {allowable_filing_key: [sub_filing_type[0] for sub_filing_type in sub_filing_types]} @@ -744,3 +878,69 @@ def get_role(jwt: JwtManager, account_id) -> str: role = SBC_STAFF return role + + +def are_digital_credentials_allowed(legal_entity: LegalEntity, jwt: JwtManager): + """Return True if the business is allowed to have/view a digital business card.""" + if not (token := pyjwt.decode(jwt.get_token_auth_header(), options={'verify_signature': False})): + return False + + if not (user := User.find_by_jwt_token(token)): + return False + + is_staff = jwt.contains_role([STAFF_ROLE]) + + # TODO: cannot identify SOLE_PROP by checking legal_entity.entity_type + is_sole_prop = legal_entity and legal_entity.entity_type == LegalEntity.EntityTypes.SOLE_PROP.value + + is_login_source_bcsc = user.login_source == 'BCSC' + + is_owner_operator = is_self_registered_owner_operator(legal_entity, user) + + return is_login_source_bcsc and is_sole_prop and is_owner_operator and not is_staff + + +def is_self_registered_owner_operator(legal_entity, user): + """Return True if the user is the owner operator of the LegalEntity.""" + if not (registration_filing := get_registration_filing(legal_entity)): + return False + + if len(proprietors := EntityRole.get_parties_by_role( + legal_entity.id, EntityRole.RoleTypes.proprietor)) <= 0: + return False + + if len(completing_parties := EntityRole.get_entity_roles_by_filing( + registration_filing.id, datetime.utcnow(), EntityRole.RoleTypes.completing_party)) <= 0: + return False + + if not ((proprietor := proprietors[0].related_entity) and proprietor.is_related_person): + return False + + if not (completing_party := completing_parties[0].related_entity): + return False + + completing_party_first_name = (completing_party.first_name or '').lower() + completing_party_last_name = (completing_party.last_name or '').lower() + proprietor_first_name = (proprietor.first_name or '').lower() + proprietor_middle_initial = (proprietor.middle_initial or '').lower() + if proprietor_middle_initial: + proprietor_first_name = f'{proprietor_first_name} {proprietor_middle_initial}' + proprietor_last_name = (proprietor.last_name or '').lower() + user_first_name = (user.firstname or '').lower() + user_last_name = (user.lastname or '').lower() + + return ( + registration_filing.submitter_id == user.id and + completing_party_first_name == proprietor_first_name and + completing_party_last_name == proprietor_last_name and + proprietor_first_name == user_first_name and + proprietor_last_name == user_last_name + ) + + +def get_registration_filing(legal_entity): + """Return the registration filing for the LegalEntity.""" + if len(registration_filings := Filing.get_filings_by_types(legal_entity.id, ["registration"])) <= 0: + return None + + return registration_filings[0] diff --git a/legal-api/src/legal_api/services/digital_credentials.py b/legal-api/src/legal_api/services/digital_credentials.py index dff779c9d5..c918eb74cf 100644 --- a/legal-api/src/legal_api/services/digital_credentials.py +++ b/legal-api/src/legal_api/services/digital_credentials.py @@ -1,13 +1,13 @@ # Copyright © 2022 Province of British Columbia # -# Licensed under the Apache License, Version 2.0 (the 'License'); +# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, +# distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. @@ -21,142 +21,239 @@ import requests -from legal_api.models import DCDefinition +from legal_api.decorators import requires_traction_auth +from legal_api.models import ( + CorpType, + DCDefinition, + DCIssuedBusinessUserCredential, + DCRevocationReason, + LegalEntity, + User +) class DigitalCredentialsService: """Provides services to do digital credentials using aca-py agent.""" - business_schema = { - "attributes": ["legalName", "foundingDate", "taxId", "homeJurisdiction", "legalType", "identifier"], - "schema_name": "business_schema", # do not change schema name. this is the name registered in aca-py agent - "schema_version": "1.0.0", # if attributes changes update schema_version to re-register - } - def __init__(self): """Initialize this object.""" self.app = None self.api_url = None - self.api_key = None - self.entity_did = None + self.api_token = None + self.public_schema_did = None + self.public_issuer_did = None + + self.business_schema_name = None + self.business_schema_version = None + self.business_schema_id = None + self.business_cred_def_id = None def init_app(self, app): """Initialize digital credentials using aca-py agent.""" self.app = app - self.api_url = app.config.get("ACA_PY_ADMIN_API_URL") - self.api_key = app.config.get("ACA_PY_ADMIN_API_KEY") - self.entity_did = app.config.get("ACA_PY_ENTITY_DID") + self.api_url = app.config.get("TRACTION_API_URL") + self.public_schema_did = app.config.get("TRACTION_PUBLIC_SCHEMA_DID") + self.public_issuer_did = app.config.get("TRACTION_PUBLIC_ISSUER_DID") + + self.business_schema_name = app.config.get("BUSINESS_SCHEMA_NAME") + self.business_schema_version = app.config.get("BUSINESS_SCHEMA_VERSION") + self.business_schema_id = app.config.get("BUSINESS_SCHEMA_ID") + self.business_cred_def_id = app.config.get("BUSINESS_CRED_DEF_ID") + with suppress(Exception): - self._register_business() - - def _register_business(self): - """Register business schema and credential definition.""" - # check for the current schema definition. - definition = DCDefinition.find_by( - credential_type=DCDefinition.CredentialType.business, - schema_name=self.business_schema["schema_name"], - schema_version=self.business_schema["schema_version"], - ) - - if definition: - if definition.is_deleted: - raise Exception( # noqa: E501; pylint: disable=broad-exception-raised - "Digital Credentials: business_schema is marked as delete, fix it." - ) - else: - # deactivate any existing schema definition before registering new one - DCDefinition.deactivate(DCDefinition.CredentialType.business) + self._register_business_definition() + + def _register_business_definition(self): + """Fetch schema and credential definition and save a Business definition.""" + try: + if not self.business_schema_id: + self.app.logger.error("Environment variable: BUSINESS_SCHEMA_ID must be configured") + raise ValueError("Environment variable: BUSINESS_SCHEMA_ID must be configured") + + if not self.business_cred_def_id: + self.app.logger.error("Environment variable: BUSINESS_CRED_DEF_ID must be configured") + raise ValueError("Environment variable: BUSINESS_CRED_DEF_ID must be configured") + + ### + # The following just a sanity check to make sure the schema and + # credential definition are stored in Traction tenant. + # These calls also include a ledger lookup to see if the schema + # and credential definition are published. + ### - schema_id = self._register_schema(self.business_schema) + # Look for a schema first, and copy it into the Traction tenant if it"s not there + if not (schema_id := self._fetch_schema(self.business_schema_id)): + raise ValueError(f"Schema with id:{self.business_schema_id}" + + " must be available in Traction tenant storage") + + # Look for a published credential definition first, and copy it into the Traction tenant if it"s not there + if not (credential_definition_id := self._fetch_credential_definition(self.business_cred_def_id)): + raise ValueError(f"Credential Definition with id:{self.business_cred_def_id}" + + " must be available in Traction tenant storage") + + # Check for the current Business definition. + definition = DCDefinition.find_by( + credential_type=DCDefinition.CredentialType.business, + schema_id=self.business_schema_id, + credential_definition_id=self.business_cred_def_id + ) + if definition and not definition.is_deleted: + return None + + # Create a new definition and add the new schema_id definition = DCDefinition( credential_type=DCDefinition.CredentialType.business, - schema_name=self.business_schema["schema_name"], - schema_version=self.business_schema["schema_version"], + schema_name=self.business_schema_name, + schema_version=self.business_schema_version, schema_id=schema_id, + credential_definition_id=credential_definition_id ) + # Lastly, save the definition definition.save() + return None + except Exception as err: + self.app.logger.error(err) + return None - if not definition.credential_definition_id: - definition.credential_definition_id = self._register_credential_definitions(definition.schema_id) - definition.save() - - def _register_schema(self, schema: dict) -> Optional[str]: - """Send a schema to the ledger.""" + @requires_traction_auth + def _fetch_schema(self, schema_id: str) -> Optional[str]: + """Find a schema in Traction storage.""" try: - response = requests.post(self.api_url + "/schemas", headers=self._get_headers(), data=json.dumps(schema)) + response = requests.get(self.api_url + "/schema-storage", + params={"schema_id": schema_id}, + headers=self._get_headers()) response.raise_for_status() - return response.json()["schema_id"] + first_or_default = next((x for x in response.json()["results"] if x["schema_id"] == schema_id), None) + return first_or_default["schema_id"] if first_or_default else None except Exception as err: - self.app.logger.error( - f"Failed to register digital credential schema {schema['schema_name']}:{schema['schema_version']}" - ) + self.app.logger.error(f"Failed to fetch schema with id:{schema_id} from Traction tenant storage") self.app.logger.error(err) raise err - def _register_credential_definitions(self, schema_id: str) -> Optional[str]: - """Send a credential definition to the ledger.""" + @requires_traction_auth + def _fetch_credential_definition(self, cred_def_id: str) -> Optional[str]: + """Find a published credential definition.""" try: - response = requests.post( - self.api_url + "/credential-definitions", - headers=self._get_headers(), - data=json.dumps( - { - "revocation_registry_size": 1000, - "schema_id": schema_id, - "support_revocation": True, - "tag": "business_schema", - } - ), - ) + response = requests.get(self.api_url + "/credential-definition-storage", + params={"cred_def_id": cred_def_id}, + headers=self._get_headers()) response.raise_for_status() - return response.json()["credential_definition_id"] + first_or_default = next((x for x in response.json()["results"] if x["cred_def_id"] == cred_def_id), None) + return first_or_default["cred_def_id"] if first_or_default else None except Exception as err: - self.app.logger.error(f"Failed to register credential definition schema_id:{schema_id}") + self.app.logger.error(f"Failed to find credential definition with id:{cred_def_id}" + + " from Traction tenant storage") self.app.logger.error(err) raise err + @requires_traction_auth def create_invitation(self) -> Optional[dict]: """Create a new connection invitation.""" try: - response = requests.post( - self.api_url + "/connections/create-invitation", headers=self._get_headers(), data={} - ) + response = requests.post(self.api_url + "/out-of-band/create-invitation", + headers=self._get_headers(), + params={"auto_accept": "true"}, + data=json.dumps({ + "handshake_protocols": ["https://didcomm.org/connections/1.0"] + })) response.raise_for_status() return response.json() except Exception as err: self.app.logger.error(err) return None - def issue_credential( - self, - connection_id: str, - definition: DCDefinition, - data: list, # list of { 'name': 'business_name', 'value': 'test_business' } - comment: str = "", - ) -> Optional[dict]: + @requires_traction_auth + def issue_credential(self, + connection_id: str, + definition: DCDefinition, + data: list, # list of { "name": "business_name", "value": "test_business" } + comment: str = "") -> Optional[dict]: """Send holder a credential, automating entire flow.""" try: - response = requests.post( - self.api_url + "/issue-credential/send", - headers=self._get_headers(), - data=json.dumps( - { - "auto_remove": True, - "comment": comment, - "connection_id": connection_id, - "cred_def_id": definition.credential_definition_id, - "credential_proposal": {"@type": "issue-credential/1.0/credential-preview", "attributes": data}, - "issuer_did": self.entity_did, - "schema_id": definition.schema_id, - "schema_issuer_did": self.entity_did, - "schema_name": definition.schema_name, - "schema_version": definition.schema_version, - "trace": True, - } - ), - ) + response = requests.post(self.api_url + "/issue-credential-2.0/send", + headers=self._get_headers(), + data=json.dumps({ + "auto_remove": "true", + "comment": comment, + "connection_id": connection_id, + "credential_preview": { + "@type": "issue-credential/2.0/credential-preview", + "attributes": data + }, + "filter": { + "indy": { + "cred_def_id": definition.credential_definition_id, + "issuer_did": self.public_issuer_did, + "schema_id": definition.schema_id, + "schema_issuer_did": self.public_schema_did, + "schema_name": definition.schema_name, + "schema_version": definition.schema_version + } + }, + "trace": True + })) + response.raise_for_status() + return response.json() + except Exception as err: + self.app.logger.error(err) + return None + + @requires_traction_auth + def fetch_credential_exchange_record(self, cred_ex_id: str) -> Optional[dict]: + """Fetch a credential exchange record.""" + try: + response = requests.get(self.api_url + "/issue-credential-2.0/records/" + cred_ex_id, + headers=self._get_headers()) + response.raise_for_status() + return response.json() + except Exception as err: + self.app.logger.error(err) + return None + + @requires_traction_auth + def revoke_credential(self, connection_id, + cred_rev_id: str, + rev_reg_id: str, + reason: DCRevocationReason) -> Optional[dict]: + """Revoke a credential.""" + try: + response = requests.post(self.api_url + "/revocation/revoke", + headers=self._get_headers(), + data=json.dumps({ + "connection_id": connection_id, + "cred_rev_id": cred_rev_id, + "rev_reg_id": rev_reg_id, + "publish": True, + "notify": True, + "notify_version": "v1_0", + "comment": reason.value if reason else "" + })) + response.raise_for_status() + return response.json() + except Exception as err: + self.app.logger.error(err) + return None + + @requires_traction_auth + def remove_connection_record(self, connection_id: str) -> Optional[dict]: + """Delete a connection.""" + try: + response = requests.delete(self.api_url + "/connections/" + connection_id, + headers=self._get_headers()) + response.raise_for_status() + return response.json() + except Exception as err: + self.app.logger.error(err) + return None + + @requires_traction_auth + def remove_credential_exchange_record(self, cred_ex_id: str) -> Optional[dict]: + """Delete a credential exchange.""" + try: + response = requests.delete(self.api_url + "/issue-credential-2.0/records/" + cred_ex_id, + headers=self._get_headers()) response.raise_for_status() return response.json() except Exception as err: @@ -164,4 +261,101 @@ def issue_credential( return None def _get_headers(self) -> dict: - return {"Content-Type": "application/json", "X-API-KEY": self.api_key} + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.app.api_token}" + } + + +class DigitalCredentialsHelpers: + """Provides helper functions for digital credentials.""" + + @staticmethod + def get_digital_credential_data(legal_entity: LegalEntity, user: User, credential_type: DCDefinition.CredentialType): + """Get the data for a digital credential.""" + if credential_type == DCDefinition.CredentialType.business: + + # Find the credential id from dc_issued_business_user_credentials and if there isn"t one create one + if not (issued_business_user_credential := DCIssuedBusinessUserCredential.find_by( + legal_entity_id=legal_entity.id, user_id=user.id)): + issued_business_user_credential = DCIssuedBusinessUserCredential( + legal_entity_id=legal_entity.id, user_id=user.id) + issued_business_user_credential.save() + + credential_id = f"{issued_business_user_credential.id:08}" + + if (business_type := CorpType.find_by_id(legal_entity.entity_type)): + business_type = business_type.full_desc + else: + business_type = legal_entity.entity_type + + registered_on_dateint = "" + if legal_entity.founding_date: + registered_on_dateint = legal_entity.founding_date.strftime("%Y%m%d") + + company_status = LegalEntity.State(legal_entity.state).name + + family_name = (user.lastname or "").strip().upper() + + given_names = " ".join([x.strip() for x in [user.firstname, user.middlename] if x and x.strip()]).upper() + + # For an SP there is only one role. This will need to be updated + # when the entity model changes and we need to support multiple roles. + role = ( + legal_entity.party_roles[0].role_type.name if (legal_entity.entity_roles and + len(legal_entity.entity_roles.all())) else "" + ).replace("_", " ").title() + + return [ + { + "name": "credential_id", + "value": credential_id or "" + }, + { + "name": "identifier", + "value": legal_entity.identifier or "" + }, + { + "name": "business_name", + "value": legal_entity.legal_name or "" + }, + { + "name": "business_type", + "value": business_type or "" + }, + { + "name": "cra_business_number", + "value": legal_entity.tax_id or "" + }, + { + "name": "registered_on_dateint", + "value": registered_on_dateint or "" + }, + { + "name": "company_status", + "value": company_status or "" + }, + { + "name": "family_name", + "value": family_name or "" + }, + { + "name": "given_names", + "value": given_names or "" + }, + { + "name": "role", + "value": role or "" + } + ] + + return None + + @staticmethod + def extract_invitation_message_id(json_message: dict): + """Extract the invitation message id from the json message.""" + if "invitation" in json_message and json_message["invitation"] is not None: + invitation_message_id = json_message["invitation"]["@id"] + else: + invitation_message_id = json_message["invitation_msg_id"] + return invitation_message_id diff --git a/legal-api/src/legal_api/services/filings/validations/agm_extension.py b/legal-api/src/legal_api/services/filings/validations/agm_extension.py new file mode 100644 index 0000000000..0edab3eed9 --- /dev/null +++ b/legal-api/src/legal_api/services/filings/validations/agm_extension.py @@ -0,0 +1,164 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Validation for the Agm Extension filing.""" +from http import HTTPStatus +from typing import Dict, Optional + +from dateutil.relativedelta import relativedelta +from flask_babel import _ as babel # noqa: N813, I004, I001; importing camelcase '_' as a name +# noqa: I003 +from legal_api.errors import Error +from legal_api.models import LegalEntity +from legal_api.utils.legislation_datetime import LegislationDatetime +from ...utils import get_bool, get_int, get_str # noqa: I003 +# noqa: I003 + +AGM_EXTENSION_PATH = "/filing/agmExtension" +EXPIRED_ERROR = "Allotted period to request extension has expired." +GRANT_FAILURE = "Fail to grant extension." + + +def validate(legal_entity: LegalEntity, filing: Dict) -> Optional[Error]: + """Validate the AGM Extension filing.""" + if not legal_entity or not filing: + return Error(HTTPStatus.BAD_REQUEST, [{"error": babel("A valid business and filing are required.")}]) + + msg = [] + + is_first_agm = get_bool(filing, f"{AGM_EXTENSION_PATH}/isFirstAgm") + + if is_first_agm: + msg.extend(first_agm_validation(legal_entity, filing)) + else: + msg.extend(subsequent_agm_validation(filing)) + + if msg: + return Error(HTTPStatus.BAD_REQUEST, msg) + + return None + + +def first_agm_validation(legal_entity: LegalEntity, filing: Dict) -> list: + """Validate filing for first AGM Extension.""" + msg = [] + + has_ext_req_for_agm_year = get_bool(filing, f"{AGM_EXTENSION_PATH}/extReqForAgmYear") + founding_date = LegislationDatetime.as_legislation_timezone_from_date(legal_entity.founding_date).date() + + if not has_ext_req_for_agm_year: + # first AGM, first extension + now = LegislationDatetime.datenow() + latest_ext_date = founding_date + relativedelta(months=18, days=5) + if now > latest_ext_date: + msg.append({"error": EXPIRED_ERROR, + "path": f"{AGM_EXTENSION_PATH}/isFirstAgm"}) + else: + total_approved_ext = get_int(filing, f"{AGM_EXTENSION_PATH}/totalApprovedExt") + extension_duration = get_int(filing, f"{AGM_EXTENSION_PATH}/extensionDuration") + if total_approved_ext != 6 or extension_duration != 6: + msg.append({"error": babel(GRANT_FAILURE)}) + else: + # first AGM, second extension or more + if not (curr_ext_expire_date_str := get_str(filing, f"{AGM_EXTENSION_PATH}/expireDateCurrExt")): + return [{"error": "Expiry date for current extension is required.", + "path": f"{AGM_EXTENSION_PATH}/expireDateCurrExt"}] + + curr_ext_expire_date =\ + LegislationDatetime.as_legislation_timezone_from_date_str(curr_ext_expire_date_str).date() + allowable_ext_date = founding_date + relativedelta(months=30) + now = LegislationDatetime.datenow() + if curr_ext_expire_date >= allowable_ext_date: + msg.append({"error": "Company has received the maximum 12 months of allowable extensions.", + "path": f"{AGM_EXTENSION_PATH}/expireDateCurrExt"}) + elif now > curr_ext_expire_date + relativedelta(days=5): + msg.append({"error": EXPIRED_ERROR, + "path": f"{AGM_EXTENSION_PATH}/expireDateCurrExt"}) + else: + total_approved_ext = get_int(filing, f"{AGM_EXTENSION_PATH}/totalApprovedExt") + extension_duration = get_int(filing, f"{AGM_EXTENSION_PATH}/extensionDuration") + + baseline = founding_date + relativedelta(months=18) + expected_total_approved_ext, expected_extension_duration =\ + _calculate_granted_ext(curr_ext_expire_date, baseline) + + if expected_total_approved_ext != total_approved_ext or\ + expected_extension_duration != extension_duration: + msg.append({"error": babel(GRANT_FAILURE)}) + + return msg + + +def subsequent_agm_validation(filing: Dict) -> list: + """Validate filing for subsequent AGM Extension.""" + msg = [] + + has_ext_req_for_agm_year = filing["filing"]["agmExtension"]["extReqForAgmYear"] + if not (prev_agm_ref_date_str := get_str(filing, f"{AGM_EXTENSION_PATH}/prevAgmRefDate")): + return [{"error": "Previous AGM date or a reference date is required.", + "path": f"{AGM_EXTENSION_PATH}/prevAgmRefDate"}] + + prev_agm_ref_date =\ + LegislationDatetime.as_legislation_timezone_from_date_str(prev_agm_ref_date_str).date() + + if not has_ext_req_for_agm_year: + # subsequent AGM, first extension + now = LegislationDatetime.datenow() + latest_ext_date = prev_agm_ref_date + relativedelta(months=15, days=5) + if now > latest_ext_date: + msg.append({"error": EXPIRED_ERROR, + "path": f"{AGM_EXTENSION_PATH}/prevAgmRefDate"}) + else: + total_approved_ext = get_int(filing, f"{AGM_EXTENSION_PATH}/totalApprovedExt") + extension_duration = get_int(filing, f"{AGM_EXTENSION_PATH}/extensionDuration") + if total_approved_ext != 6 or extension_duration != 6: + msg.append({"error": babel(GRANT_FAILURE)}) + else: + # subsequent AGM, second extension or more + if not (curr_ext_expire_date_str := get_str(filing, f"{AGM_EXTENSION_PATH}/expireDateCurrExt")): + return [{"error": "Expiry date for current extension is required.", + "path": f"{AGM_EXTENSION_PATH}/expireDateCurrExt"}] + + curr_ext_expire_date =\ + LegislationDatetime.as_legislation_timezone_from_date_str(curr_ext_expire_date_str).date() + + allowable_ext_date = prev_agm_ref_date + relativedelta(months=12) + now = LegislationDatetime.datenow() + + if curr_ext_expire_date >= allowable_ext_date: + msg.append({"error": "Company has received the maximum 12 months of allowable extensions.", + "path": f"{AGM_EXTENSION_PATH}/expireDateCurrExt"}) + elif now > curr_ext_expire_date + relativedelta(days=5): + msg.append({"error": EXPIRED_ERROR, + "path": f"{AGM_EXTENSION_PATH}/expireDateCurrExt"}) + else: + total_approved_ext = get_int(filing, f"{AGM_EXTENSION_PATH}/totalApprovedExt") + extension_duration = get_int(filing, f"{AGM_EXTENSION_PATH}/extensionDuration") + + expected_total_approved_ext, expected_extension_duration =\ + _calculate_granted_ext(curr_ext_expire_date, prev_agm_ref_date) + + if expected_total_approved_ext != total_approved_ext or\ + expected_extension_duration != extension_duration: + msg.append({"error": babel(GRANT_FAILURE)}) + + return msg + + +def _calculate_granted_ext(curr_ext_expire_date, baseline_date) -> tuple: + """Calculate expected total approved extension and extension duration.""" + total_approved_ext = relativedelta(curr_ext_expire_date, baseline_date).months + extension_duration = min(12 - total_approved_ext, 6) + total_approved_ext += extension_duration + + return total_approved_ext, extension_duration diff --git a/legal-api/src/legal_api/services/filings/validations/agm_location_change.py b/legal-api/src/legal_api/services/filings/validations/agm_location_change.py new file mode 100644 index 0000000000..b4c5ff86be --- /dev/null +++ b/legal-api/src/legal_api/services/filings/validations/agm_location_change.py @@ -0,0 +1,47 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Validation for the Agm Location Change filing.""" +from http import HTTPStatus +from typing import Dict, Final, Optional + +from flask_babel import _ as babel # noqa: N813, I004, I001; importing camelcase '_' as a name +# noqa: I003 +from legal_api.errors import Error +from legal_api.models import LegalEntity +from legal_api.services.utils import get_int +from legal_api.utils.legislation_datetime import LegislationDatetime +# noqa: I003 + + +def validate(legal_entity: LegalEntity, filing: Dict) -> Optional[Error]: + """Validate the Agm Location Change filing.""" + if not legal_entity or not filing: + return Error(HTTPStatus.BAD_REQUEST, [{"error": babel("A valid business and filing are required.")}]) + + msg = [] + + agm_year_path: Final = "/filing/agmLocationChange/year" + year = get_int(filing, agm_year_path) + if year: + expected_min = LegislationDatetime.now().year - 2 + expected_max = LegislationDatetime.now().year + 1 + if expected_min > year or year > expected_max: + msg.append({"error": "AGM year must be between -2 or +1 year from current year.", "path": agm_year_path}) + else: + msg.append({"error": "Invalid AGM year.", "path": agm_year_path}) + + if msg: + return Error(HTTPStatus.BAD_REQUEST, msg) + + return None diff --git a/legal-api/src/legal_api/services/filings/validations/alteration.py b/legal-api/src/legal_api/services/filings/validations/alteration.py index 7cac8b4a83..8a1789ded2 100644 --- a/legal-api/src/legal_api/services/filings/validations/alteration.py +++ b/legal-api/src/legal_api/services/filings/validations/alteration.py @@ -84,7 +84,7 @@ def company_name_validation(filing): error_msg = """The name type associated with the name request number entered cannot be used for this transaction type.""" - if not nr_response["requestTypeCd"] in ("CCR", "CCP", "BEC", "BECV"): + if not nr_response["requestTypeCd"] in ("CCR", "CCP", "BEC", "BECR", "BECV", "CCV", "UC", "ULCB", "ULBE"): msg.append({"error": babel(error_msg).replace("\n", "").replace(" ", ""), "path": nr_path}) if not validation_result["is_consumable"]: diff --git a/legal-api/src/legal_api/services/filings/validations/amalgamation_application.py b/legal-api/src/legal_api/services/filings/validations/amalgamation_application.py new file mode 100644 index 0000000000..79d378fd90 --- /dev/null +++ b/legal-api/src/legal_api/services/filings/validations/amalgamation_application.py @@ -0,0 +1,227 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Validation for the Amalgamation Application filing.""" +from http import HTTPStatus +from typing import Dict, Final, Optional + +from flask_babel import _ as babel # noqa: N813, I004, I001; importing camelcase '_' as a name +from legal_api.errors import Error +from legal_api.models import Filing, LegalEntity, EntityRole +from legal_api.services import STAFF_ROLE +from legal_api.services.bootstrap import AccountService +from legal_api.services.filings.validations.common_validations import ( + validate_court_order, + validate_name_request, + validate_share_structure, +) +from legal_api.services.filings.validations.incorporation_application import validate_offices +from legal_api.services.utils import get_str +from legal_api.utils.auth import jwt +# noqa: I003 + + +def validate(legal_entity: LegalEntity, amalgamation_json: Dict, account_id) -> Optional[Error]: + """Validate the Amalgamation Application filing.""" + filing_type = 'amalgamationApplication' + if not amalgamation_json: + return Error(HTTPStatus.BAD_REQUEST, [{'error': babel('A valid filing is required.')}]) + msg = [] + + legal_type_path = f'/filing/{filing_type}/nameRequest/legalType' + legal_type = get_str(amalgamation_json, legal_type_path) + if not legal_type: + msg.append({'error': babel('Legal type is required.'), 'path': legal_type_path}) + return msg # Cannot continue validation without legal_type + + amalgamation_type = get_str(amalgamation_json, f'/filing/{filing_type}/type') + + if amalgamation_json.get('filing', {}).get(filing_type, {}).get('nameRequest', {}).get('nrNumber', None): + # Adopt from one of the amalgamating businesses contains name not nrNumber + msg.extend(validate_name_request(amalgamation_json, legal_type, filing_type)) + + msg.extend(validate_party(amalgamation_json, amalgamation_type, filing_type)) + if amalgamation_type == 'regular': + msg.extend(validate_offices(amalgamation_json, filing_type)) + err = validate_share_structure(amalgamation_json, filing_type) + if err: + msg.extend(err) + + msg.extend(validate_amalgamation_court_order(amalgamation_json, filing_type)) + msg.extend(validate_amalgamating_businesses(amalgamation_json, filing_type, legal_type, account_id)) + + if msg: + return Error(HTTPStatus.BAD_REQUEST, msg) + return None + + +def validate_amalgamating_businesses( # pylint: disable=too-many-branches,too-many-statements,too-many-locals + amalgamation_json, + filing_type, + legal_type, + account_id) -> list: + """Validate amalgamating businesses.""" + is_staff = jwt.validate_roles([STAFF_ROLE]) + msg = [] + amalgamating_businesses_json = amalgamation_json.get('filing', {}) \ + .get(filing_type, {})\ + .get('amalgamatingBusinesses', []) + amalgamating_businesses_path = f'/filing/{filing_type}/amalgamatingBusinesses' + is_any_limited = False + is_any_ccc = False + is_any_ben = False + is_any_ulc = False + is_any_expro_a = False + amalgamating_businesses = {} + for amalgamating_business_json in amalgamating_businesses_json: + if identifier := amalgamating_business_json.get('identifier'): + if not (legal_entity := LegalEntity.find_by_identifier(identifier)): + continue + + amalgamating_businesses[identifier] = legal_entity + + if legal_entity.entity_type == LegalEntity.EntityTypes.BCOMP.value: + is_any_ben = True + elif legal_entity.entity_type == LegalEntity.EntityTypes.COMP.value: + is_any_limited = True + elif legal_entity.entity_type == LegalEntity.EntityTypes.BC_CCC.value: + is_any_ccc = True + elif legal_entity.entity_type == LegalEntity.EntityTypes.BC_ULC_COMPANY.value: + is_any_ulc = True + elif ((corp_number := amalgamating_business_json.get('corpNumber')) and corp_number.startswith('A')): + if ((foreign_jurisdiction := amalgamating_business_json.get('foreignJurisdiction')) and + foreign_jurisdiction.get('country') == 'CA' and foreign_jurisdiction.get('region') == 'BC'): + is_any_expro_a = True + is_any_bc_company = (is_any_ben or is_any_limited or is_any_ccc or is_any_ulc) + + for amalgamating_business_json in amalgamating_businesses_json: + identifier = amalgamating_business_json.get('identifier') + foreign_legal_name = amalgamating_business_json.get('legalName') + is_foreign_business = bool(foreign_legal_name) + amalgamating_business = amalgamating_businesses.get(identifier) + + if amalgamating_business: + if amalgamating_business.state == LegalEntity.State.HISTORICAL: + msg.append({ + 'error': f'Cannot amalgamate with {identifier} which is in historical state.', + 'path': amalgamating_businesses_path + }) + elif _has_future_effective_filing(amalgamating_business): + msg.append({ + 'error': f'{identifier} has a future effective filing.', + 'path': amalgamating_businesses_path + }) + + if not is_staff: + if amalgamating_business: + if not _is_business_affliated(identifier, account_id): + msg.append({ + 'error': f'{identifier} is not affiliated with the currently selected BC Registries account.', + 'path': amalgamating_businesses_path + }) + + if not amalgamating_business.good_standing: + msg.append({ + 'error': f'{identifier} is not in good standing.', + 'path': amalgamating_businesses_path + }) + elif identifier: + msg.append({ + 'error': f'A business with identifier:{identifier} not found.', + 'path': amalgamating_businesses_path + }) + + if is_foreign_business: + msg.append({ + 'error': (f'{foreign_legal_name} foreign corporation cannot ' + 'be amalgamated except by Registries staff.'), + 'path': amalgamating_businesses_path + }) + else: + if is_foreign_business: + if legal_type == LegalEntity.EntityTypes.BC_ULC_COMPANY.value and is_any_bc_company: + msg.append({ + 'error': (f'{foreign_legal_name} foreign corporation must not amalgamate with ' + 'a BC company to form a BC Unlimited Liability Company.'), + 'path': amalgamating_businesses_path + }) + + if is_any_ulc: + msg.append({ + 'error': ('A BC Unlimited Liability Company cannot amalgamate with ' + f'a foreign company {foreign_legal_name}.'), + 'path': amalgamating_businesses_path + }) + + if legal_type == LegalEntity.EntityTypes.BC_CCC.value and not is_any_ccc: + msg.append({ + 'error': ('A BC Community Contribution Company must amalgamate to form ' + 'a new BC Community Contribution Company.'), + 'path': amalgamating_businesses_path + }) + elif (legal_type in [LegalEntity.EntityTypes.BC_CCC.value, LegalEntity.EntityTypes.BC_ULC_COMPANY.value] and + is_any_expro_a and is_any_bc_company): + msg.append({ + 'error': ('An extra-Pro cannot amalgamate with anything to become ' + 'a BC Unlimited Liability Company or a BC Community Contribution Company.'), + 'path': amalgamating_businesses_path + }) + + return msg + + +def _is_business_affliated(identifier, account_id): + if (account_response := AccountService.get_account_by_affiliated_identifier(identifier)) and \ + (orgs := account_response.get('orgs')) and str(orgs[0].get('id')) == account_id: + return True + return False + + +def _has_future_effective_filing(amalgamating_business: LegalEntity): + if Filing.get_filings_by_status(amalgamating_business.id, + [Filing.Status.PAID.value, Filing.Status.PENDING.value]): + return True + return False + + +def validate_party(filing: Dict, amalgamation_type, filing_type) -> list: + """Validate party.""" + msg = [] + completing_parties = 0 + director_parties = 0 + parties = filing['filing'][filing_type]['parties'] + for party in parties: # pylint: disable=too-many-nested-blocks; # noqa: E501 + for role in party.get('roles', []): + role_type = role.get('roleType').lower().replace(' ', '_') + if role_type == EntityRole.RoleTypes.completing_party.name: + completing_parties += 1 + elif role_type == EntityRole.RoleTypes.director.name: + director_parties += 1 + + party_path = f'/filing/{filing_type}/parties' + if amalgamation_type == 'regular' and (completing_parties < 1 or director_parties < 1): + msg.append({'error': 'At least one Director and a Completing Party is required.', 'path': party_path}) + elif amalgamation_type in ['vertical', 'horizontal'] and completing_parties == 0: + msg.append({'error': 'A Completing Party is required.', 'path': party_path}) + + return msg + + +def validate_amalgamation_court_order(filing: Dict, filing_type) -> list: + """Validate court order.""" + if court_order := filing.get('filing', {}).get(filing_type, {}).get('courtOrder', None): + court_order_path: Final = f'/filing/{filing_type}/courtOrder' + err = validate_court_order(court_order_path, court_order) + if err: + return err + return [] diff --git a/legal-api/src/legal_api/services/filings/validations/correction.py b/legal-api/src/legal_api/services/filings/validations/correction.py index ea32b67ab9..1bb2e61a23 100644 --- a/legal-api/src/legal_api/services/filings/validations/correction.py +++ b/legal-api/src/legal_api/services/filings/validations/correction.py @@ -1,13 +1,13 @@ # Copyright © 2019 Province of British Columbia # -# Licensed under the Apache License, Version 2.0 (the 'License'); +# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an 'AS IS' BASIS, +# distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. @@ -19,6 +19,7 @@ from dateutil.relativedelta import relativedelta from flask_babel import _ +from legal_api.core.filing_helper import is_special_resolution_correction_by_filing_json from legal_api.errors import Error from legal_api.models import Filing, LegalEntity, PartyRole from legal_api.services import STAFF_ROLE, NaicsService @@ -138,6 +139,28 @@ def _validate_special_resolution_correction(filing_dict, legal_type, msg): msg.extend(court_order_validation(filing_dict)) if filing_dict.get("filing", {}).get(filing_type, {}).get("correction", {}).get("rulesFileKey", None): msg.extend(rules_change_validation(filing_dict)) + if filing_dict.get("filing", {}).get(filing_type, {}).get("correction", {}).get("memorandumFileKey", None): + msg.extend(memorandum_change_validation(filing_dict)) + if is_special_resolution_correction_by_filing_json(filing_dict.get("filing", {})): + _validate_roles_parties_correction(filing_dict, legal_type, filing_type, msg) + + +def _validate_roles_parties_correction(filing_dict, legal_type, filing_type, msg): + if filing_dict.get("filing", {}).get("correction", {}).get("parties", None): + err = validate_roles(filing_dict, legal_type, filing_type) + if err: + msg.extend(err) + # FUTURE: this should be removed when COLIN sync back is no longer required. + err = validate_parties_names(filing_dict, legal_type, filing_type) + if err: + msg.extend(err) + + err = validate_parties_mailing_address(filing_dict, legal_type, filing_type) + if err: + msg.extend(err) + else: + err_path = f"/filing/{filing_type}/parties/roles" + msg.append({"error": "Parties list cannot be empty or null", "path": err_path}) def validate_party(filing: Dict, legal_type: str) -> list: @@ -235,3 +258,17 @@ def rules_change_validation(filing): msg.extend(rules_err) return msg return [] + + +def memorandum_change_validation(filing): + """Validate memorandum change.""" + msg = [] + memorandum_file_key_path: Final = "/filing/correction/memorandumFileKey" + memorandum_file_key: Final = get_str(filing, memorandum_file_key_path) + + if memorandum_file_key: + rules_err = validate_pdf(memorandum_file_key, memorandum_file_key_path) + if rules_err: + msg.extend(rules_err) + return msg + return [] diff --git a/legal-api/src/legal_api/services/filings/validations/validation.py b/legal-api/src/legal_api/services/filings/validations/validation.py index 475f814d4b..3e94acc4ba 100644 --- a/legal-api/src/legal_api/services/filings/validations/validation.py +++ b/legal-api/src/legal_api/services/filings/validations/validation.py @@ -22,7 +22,10 @@ from legal_api.services.utils import get_str from .admin_freeze import validate as admin_freeze_validate +from .agm_extension import validate as agm_extension_validate +from .agm_location_change import validate as agm_location_change_validate from .alteration import validate as alteration_validate +from .amalgamation_application import validate as amalgamation_application_validate from .annual_report import validate as annual_report_validate from .change_of_address import validate as coa_validate from .change_of_directors import validate as cod_validate @@ -47,7 +50,7 @@ # pylint: disable=too-many-branches,too-many-statements -def validate(legal_entity: LegalEntity, filing_json: Dict) -> Error: +def validate(legal_entity: LegalEntity, filing_json: Dict, account_id=None) -> Error: """Validate the filing JSON.""" err = validate_against_schema(filing_json) if err: @@ -185,6 +188,15 @@ def validate(legal_entity: LegalEntity, filing_json: Dict) -> Error: elif k == Filing.FILINGS["continuationOut"].get("name"): err = continuation_out_validate(legal_entity, filing_json) + elif k == Filing.FILINGS['agmLocationChange'].get('name'): + err = agm_location_change_validate(legal_entity, filing_json) + + elif k == Filing.FILINGS['agmExtension'].get('name'): + err = agm_extension_validate(legal_entity, filing_json) + + elif k == Filing.FILINGS['amalgamationApplication'].get('name'): + err = amalgamation_application_validate(legal_entity, filing_json, account_id) + if err: return err diff --git a/legal-api/src/legal_api/services/pdf_service.py b/legal-api/src/legal_api/services/pdf_service.py index d539f550bc..5fb8b1dabd 100644 --- a/legal-api/src/legal_api/services/pdf_service.py +++ b/legal-api/src/legal_api/services/pdf_service.py @@ -1,4 +1,4 @@ -# Copyright © 2021 Province of British Columbia +# Copyright © 2023 Province of British Columbia # # Licensed under the Apache License, Version 2.0 (the 'License'); # you may not use this file except in compliance with the License. @@ -12,7 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. """This module is a wrapper for Pdf Services.""" + import io +from dataclasses import dataclass +from datetime import datetime +from typing import Optional import PyPDF2 from flask import current_app @@ -21,9 +25,20 @@ from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen import canvas +from legal_api.reports.registrar_meta import RegistrarInfo from legal_api.utils.legislation_datetime import LegislationDatetime +@dataclass +class RegistrarStampData: + """Data class for Registrar's stamp.""" + + certify_date: datetime + identifier: str + file_name: Optional[str] = None + is_correction: bool = False + + class PdfService: """Pdf Services.""" @@ -58,8 +73,9 @@ def stamp_pdf(input_pdf, watermark, only_first_page=True): return output @classmethod - def create_registrars_stamp(cls, registrars_signature_image, incorp_date, incorp_num, file_name): + def create_registrars_stamp(cls, data: RegistrarStampData): """Create a Registrar's stamp to certify documents.""" + registrar_info = RegistrarInfo.get_registrar_info(data.certify_date) buffer = io.BytesIO() can = canvas.Canvas(buffer, pagesize=letter) doc_width = letter[0] @@ -67,14 +83,20 @@ def create_registrars_stamp(cls, registrars_signature_image, incorp_date, incorp image_x_margin = doc_width - 130 image_y_margin = doc_height - 150 - can.drawImage( - registrars_signature_image, image_x_margin, image_y_margin, width=100, preserveAspectRatio=True, mask="auto" - ) - - text = "Filed on " + LegislationDatetime.format_as_report_string(incorp_date) - if file_name: - text += "\nFile Name: " + file_name - text += "\nIncorporation Number: " + incorp_num + can.drawImage(registrar_info["signatureAndText"], + image_x_margin, + image_y_margin, + width=100, + preserveAspectRatio=True, + mask="auto") + + certify_date = LegislationDatetime.as_legislation_timezone(data.certify_date) + text = "Filed on " + LegislationDatetime.format_as_report_string(certify_date) + if data.is_correction: + text += " (Corrected)" + if data.file_name: + text += "\nFile Name: " + data.file_name + text += "\nIncorporation Number: " + data.identifier text_x_margin = 32 text_y_margin = doc_height - 42 diff --git a/legal-api/src/legal_api/services/utils.py b/legal-api/src/legal_api/services/utils.py index eba3d1abeb..485f38c069 100644 --- a/legal-api/src/legal_api/services/utils.py +++ b/legal-api/src/legal_api/services/utils.py @@ -72,3 +72,17 @@ def get_bool(filing: Dict, path: str) -> str: return bool(raw) except (IndexError, KeyError, TypeError, ValueError): return None + + +def get_int(filing: Dict, path: str) -> str: + """Extract int from the JSON filing, at the provided path. + + Args: + filing (Dict): A valid registry_schema filing. + path (str): The path to the property. + """ + try: + raw = dpath.util.get(filing, path) + return int(raw) + except (IndexError, KeyError, TypeError, ValueError): + return None diff --git a/legal-api/src/legal_api/services/warnings/business/business_checks/firms.py b/legal-api/src/legal_api/services/warnings/business/business_checks/firms.py index 08f066899c..537acfefec 100644 --- a/legal-api/src/legal_api/services/warnings/business/business_checks/firms.py +++ b/legal-api/src/legal_api/services/warnings/business/business_checks/firms.py @@ -295,8 +295,15 @@ def check_address(address: Address, address_type: str, referer: BusinessWarningR if not address.country: result.append(get_address_business_warning(referer, address_type, BusinessWarnings.NO_ADDRESS_COUNTRY)) if not address.postal_code: - result.append(get_address_business_warning(referer, address_type, BusinessWarnings.NO_ADDRESS_POSTAL_CODE)) - if not address.region: - result.append(get_address_business_warning(referer, address_type, BusinessWarnings.NO_ADDRESS_REGION)) + result.append(get_address_business_warning(referer, + address_type, + BusinessWarnings.NO_ADDRESS_POSTAL_CODE)) + + if (referer == BusinessWarningReferers.BUSINESS_OFFICE + and address_type == Address.DELIVERY + and not address.region): + result.append(get_address_business_warning(referer, + address_type, + BusinessWarnings.NO_ADDRESS_REGION)) return result diff --git a/legal-api/src/legal_api/utils/formatting.py b/legal-api/src/legal_api/utils/formatting.py new file mode 100644 index 0000000000..6a3202ae5f --- /dev/null +++ b/legal-api/src/legal_api/utils/formatting.py @@ -0,0 +1,24 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Custom formatting.""" +import decimal + + +def float_to_str(f, precision=17): + """Convert the given float to a string without resorting to scientific notation.""" + ctx = decimal.Context() # create a new context for this task + ctx.prec = precision + + value = ctx.create_decimal(repr(f)) + return format(value, "f") diff --git a/legal-api/src/legal_api/utils/legislation_datetime.py b/legal-api/src/legal_api/utils/legislation_datetime.py index d7e579e94c..cc07746613 100644 --- a/legal-api/src/legal_api/utils/legislation_datetime.py +++ b/legal-api/src/legal_api/utils/legislation_datetime.py @@ -28,6 +28,11 @@ def now() -> datetime: """Construct a datetime using the legislation timezone.""" return datetime.now().astimezone(pytz.timezone(current_app.config.get("LEGISLATIVE_TIMEZONE"))) + @staticmethod + def datenow() -> date: + """Construct a date using the legislation timezone.""" + return LegislationDatetime.now().date() + @staticmethod def tomorrow_midnight() -> datetime: """Construct a datetime tomorrow midnight using the legislation timezone.""" diff --git a/legal-api/src/legal_api/version.py b/legal-api/src/legal_api/version.py index c699503233..ded3bd3719 100644 --- a/legal-api/src/legal_api/version.py +++ b/legal-api/src/legal_api/version.py @@ -22,4 +22,4 @@ Development release segment: .devN """ -__version__ = "2.85.0" # pylint: disable=invalid-name +__version__ = "2.99.0" # pylint: disable=invalid-name diff --git a/legal-api/tests/unit/core/test_filing_ledger.py b/legal-api/tests/unit/core/test_filing_ledger.py index cffa56f6cc..4805d45197 100644 --- a/legal-api/tests/unit/core/test_filing_ledger.py +++ b/legal-api/tests/unit/core/test_filing_ledger.py @@ -42,6 +42,9 @@ def load_ledger(legal_entity, founding_date): elif filing_meta["name"] == "dissolution": filing["filing"]["dissolution"] = {} filing["filing"]["dissolution"]["dissolutionType"] = "voluntary" + elif filing_meta["name"] == "amalgamationApplication": + filing["filing"]["amalgamationApplication"] = {} + filing["filing"]["amalgamationApplication"]["type"] = "regular" f = factory_completed_filing(legal_entity, filing, filing_date=founding_date + datedelta.datedelta(months=i)) for c in range(i): comment = Comment() @@ -75,7 +78,7 @@ def test_simple_ledger_search(session): alteration = next((f for f in ledger if f.get("name") == "alteration"), None) assert alteration - assert 15 == len(alteration.keys()) + assert 16 == len(alteration.keys()) assert "availableOnPaperOnly" in alteration assert "effectiveDate" in alteration assert "filingId" in alteration @@ -84,6 +87,7 @@ def test_simple_ledger_search(session): assert "status" in alteration assert "submittedDate" in alteration assert "submitter" in alteration + assert "displayLedger" in alteration # assert alteration['commentsLink'] # assert alteration['correctionLink'] # assert alteration['filingLink'] @@ -115,3 +119,10 @@ def test_common_ledger_items(session): ) common_ledger_items = CoreFiling.common_ledger_items(identifier, completed_filing) assert common_ledger_items["documentsLink"] is not None + assert common_ledger_items["displayLedger"] is True + + filing["filing"]["header"]["name"] = "adminFreeze" + completed_filing = \ + factory_completed_filing(legal_entity, filing, filing_date=founding_date + datedelta.datedelta(months=1), filing_type="adminFreeze") + common_ledger_items = CoreFiling.common_ledger_items(identifier, completed_filing) + assert common_ledger_items["displayLedger"] is False diff --git a/legal-api/tests/unit/models/__init__.py b/legal-api/tests/unit/models/__init__.py index 74b484678c..ba7a4b702d 100644 --- a/legal-api/tests/unit/models/__init__.py +++ b/legal-api/tests/unit/models/__init__.py @@ -16,6 +16,7 @@ import base64 import uuid +from datedelta import datedelta from freezegun import freeze_time from registry_schemas.example_data import ANNUAL_REPORT from sqlalchemy_continuum import versioning_manager @@ -186,7 +187,11 @@ def factory_legal_entity_mailing_address(legal_entity): return legal_entity -def factory_filing(legal_entity, data_dict, filing_date=FROZEN_DATETIME, filing_type=None, filing_sub_type=None): +def factory_filing(legal_entity, data_dict, + filing_date=FROZEN_DATETIME, + filing_type=None, + filing_sub_type=None, + is_future_effective=False): """Create a filing.""" filing = Filing() filing.legal_entity_id = legal_entity.id @@ -196,6 +201,8 @@ def factory_filing(legal_entity, data_dict, filing_date=FROZEN_DATETIME, filing_ filing._filing_type = filing_type if filing_sub_type: filing._filing_sub_type = filing_sub_type + if is_future_effective: + filing.effective_date = datetime.utcnow() + datedelta(days=5) try: filing.save() except Exception as err: diff --git a/legal-api/tests/unit/models/test_amalgamating_business.py b/legal-api/tests/unit/models/test_amalgamating_business.py new file mode 100644 index 0000000000..16b4bffc31 --- /dev/null +++ b/legal-api/tests/unit/models/test_amalgamating_business.py @@ -0,0 +1,85 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Tests to assure the AmalgamatingBusiness Model. +Test-Suite to ensure that the AmalgamatingBusiness Model is working as expected. + +""" +from datetime import datetime + +from registry_schemas.example_data import ( + ALTERATION_FILING_TEMPLATE, + ANNUAL_REPORT, + CHANGE_OF_DIRECTORS, + CORRECTION_AR, + COURT_ORDER, + FILING_HEADER, + SPECIAL_RESOLUTION, +) + +from legal_api.models import AmalgamatingBusiness, Amalgamation, LegalEntity +from tests.unit.models import ( + factory_legal_entity, + factory_filing, +) + + +def test_valid_amalgamating_business_save(session): + """Assert that a valid amalgamating business can be saved.""" + b = factory_legal_entity('CP1234567') + b.save() + + filing = factory_filing(b, ANNUAL_REPORT) + filing.save() + + amalgamation = Amalgamation( + amalgamation_type=Amalgamation.AmalgamationTypes.horizontal, + business_id=b.id, + filing_id=filing.id, + amalgamation_date=datetime.utcnow(), + court_approval=True + ) + + amalgamation.save() + + amalgamating_business_1 = AmalgamatingBusiness( + role=AmalgamatingBusiness.Role.amalgamating, + foreign_jurisdiction="CA", + foreign_jurisdiction_region="AB", + foreign_name="Testing123", + foreign_corp_num="123456789", + business_id=b.id, + amalgamation_id=amalgamation.id + ) + amalgamating_business_1.save() + + amalgamating_business_2 = AmalgamatingBusiness( + role=AmalgamatingBusiness.Role.holding, + foreign_jurisdiction="CA", + foreign_jurisdiction_region="AB", + foreign_name="Testing123", + foreign_corp_num="123456789", + business_id=b.id, + amalgamation_id=amalgamation.id + ) + amalgamating_business_2.save() + + # verify + assert amalgamating_business_1.id + assert amalgamating_business_2.id + for type in AmalgamatingBusiness.Role: + assert type in [AmalgamatingBusiness.Role.holding, + AmalgamatingBusiness.Role.amalgamating, + AmalgamatingBusiness.Role.primary] diff --git a/legal-api/tests/unit/models/test_amalgamation.py b/legal-api/tests/unit/models/test_amalgamation.py new file mode 100644 index 0000000000..fa36c94099 --- /dev/null +++ b/legal-api/tests/unit/models/test_amalgamation.py @@ -0,0 +1,83 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to assure the Amalgamation Model. +Test-Suite to ensure that the Amalgamation Model is working as expected. +""" +from tests.unit.models import ( + factory_legal_entity, + factory_filing, +) +from legal_api.models import Amalgamation +from registry_schemas.example_data import ( + ALTERATION_FILING_TEMPLATE, + ANNUAL_REPORT, + CHANGE_OF_DIRECTORS, + CORRECTION_AR, + COURT_ORDER, + FILING_HEADER, + SPECIAL_RESOLUTION, +) +from datetime import datetime + + +def test_valid_amalgamation_save(session): + """Assert that a valid amalgamation can be saved.""" + + b = factory_legal_entity('CP1234567') + + b.save() + + filing = factory_filing(b, ANNUAL_REPORT) + + filing.save() + + amalgamation_1 = Amalgamation( + amalgamation_type=Amalgamation.AmalgamationTypes.horizontal, + legal_entity_id=b.id, + filing_id=filing.id, + amalgamation_date=datetime.utcnow(), + court_approval=True + ) + + amalgamation_1.save() + + amalgamation_2 = Amalgamation( + amalgamation_type=Amalgamation.AmalgamationTypes.vertical, + legal_entity_id=b.id, + filing_id=filing.id, + amalgamation_date=datetime.utcnow(), + court_approval=True + ) + + amalgamation_2.save() + + amalgamation_3 = Amalgamation( + amalgamation_type=Amalgamation.AmalgamationTypes.regular, + legal_entity_id=b.id, + filing_id=filing.id, + amalgamation_date=datetime.utcnow(), + court_approval=True + ) + + amalgamation_3.save() + + # verify + assert amalgamation_1.id + assert amalgamation_2.id + assert amalgamation_3.id + for type in Amalgamation.AmalgamationTypes: + assert type in [Amalgamation.AmalgamationTypes.horizontal, + Amalgamation.AmalgamationTypes.vertical, + Amalgamation.AmalgamationTypes.regular] diff --git a/legal-api/tests/unit/models/test_dc_definition.py b/legal-api/tests/unit/models/test_dc_definition.py index 071e46714d..7429b0d19e 100644 --- a/legal-api/tests/unit/models/test_dc_definition.py +++ b/legal-api/tests/unit/models/test_dc_definition.py @@ -64,19 +64,21 @@ def test_find_by(session): """Assert that the method returns correct value.""" # definition = create_dc_definition() - res = DCDefinition.find_by(DCDefinition.CredentialType.business, "business_schema", schema_version="1.0.0") + res = DCDefinition.find_by(credential_type=DCDefinition.CredentialType.business, + schema_id="test_schema_id", + credential_definition_id="test_credential_definition_id", + ) assert res - # assert res.id == definition.id def create_dc_definition(): """Create new dc_definition object.""" definition = DCDefinition( credential_type=DCDefinition.CredentialType.business, - schema_name="business_schema", + schema_name="test_business_schema", schema_version="1.0.0", - schema_id=f"{str(uuid.uuid4().hex)}:2:business_schema:1.0.0", - credential_definition_id=f"{str(uuid.uuid4().hex)}:3:CL:146949:business_schema", + schema_id="test_schema_id", + credential_definition_id="test_credential_definition_id" ) definition.save() return definition diff --git a/legal-api/tests/unit/models/test_legal_entity.py b/legal-api/tests/unit/models/test_legal_entity.py index ddff38107e..8480254651 100644 --- a/legal-api/tests/unit/models/test_legal_entity.py +++ b/legal-api/tests/unit/models/test_legal_entity.py @@ -25,7 +25,15 @@ from flask import current_app from legal_api.exceptions import BusinessException -from legal_api.models import AlternateName, ColinEntity, EntityRole, LegalEntity +from legal_api.models import ( + AlternateName, + AmalgamatingBusiness, + Amalgamation, + ColinEntity, + EntityRole, + Filing, + LegalEntity +) from legal_api.utils.legislation_datetime import LegislationDatetime from tests import EPOCH_DATETIME, TIMEZONE_OFFSET from tests.unit import has_expected_date_str_format @@ -493,6 +501,63 @@ def test_continued_in_business(session): ) +@pytest.mark.parametrize('test_name,existing_business_state', [ + ('EXIST', LegalEntity.State.HISTORICAL), + ('NOT_EXIST', LegalEntity.State.ACTIVE), +]) +def test_amalgamated_into_business_json(session, test_name, existing_business_state): + """Assert that the amalgamated into is in json.""" + filing = Filing() + filing.save() + + existing_business = LegalEntity( + legal_name='Test - Amalgamating Legal Name', + legal_type='BC', + founding_date=datetime.utcfromtimestamp(0), + dissolution_date=datetime.now(), + identifier='BC1234567', + state=existing_business_state, + state_filing_id=filing.id + ) + existing_business.save() + + if test_name == 'EXIST': + business = LegalEntity( + legal_name='Test - Legal Name', + legal_type='BC', + founding_date=datetime.utcfromtimestamp(0), + identifier='BC1234568', + state=LegalEntity.State.ACTIVE, + ) + amalgamation = Amalgamation() + amalgamation.filing_id = filing.id + amalgamation.amalgamation_type = 'regular' + amalgamation.amalgamation_date = datetime.now() + amalgamation.court_approval = True + + amalgamating_business = AmalgamatingBusiness() + amalgamating_business.role = 'amalgamating' + amalgamating_business.legal_entity_id = existing_business.id + amalgamation.amalgamating_businesses.append(amalgamating_business) + + business.amalgamation.append(amalgamation) + business.save() + + business_json = existing_business.json() + + if test_name == 'EXIST': + assert not 'stateFiling' in business_json + assert 'amalgamatedInto' in business_json + assert business_json['amalgamatedInto']['amalgamationDate'] == amalgamation.amalgamation_date.isoformat() + assert business_json['amalgamatedInto']['amalgamationType'] == amalgamation.amalgamation_type.name + assert business_json['amalgamatedInto']['courtApproval'] == amalgamation.court_approval + assert business_json['amalgamatedInto']['identifier'] == business.identifier + assert business_json['amalgamatedInto']['legalName'] == business.legal_name + else: + assert not 'amalgamatedInto' in business_json + assert 'stateFiling' in business_json + + @pytest.mark.parametrize("entity_type", [("CP"), ("BEN"), ("BC"), ("ULC"), ("CC")]) def test_legal_name_non_firm(session, entity_type): """Assert that correct legal name returned for non-firm entity types.""" diff --git a/legal-api/tests/unit/resources/v2/test_business.py b/legal-api/tests/unit/resources/v2/test_business.py index c86f12a1a5..231e0e5cb0 100644 --- a/legal-api/tests/unit/resources/v2/test_business.py +++ b/legal-api/tests/unit/resources/v2/test_business.py @@ -88,7 +88,11 @@ def test_create_bootstrap_failure_filing(client, jwt): @integration_affiliation -@pytest.mark.parametrize("filing_name", ["incorporationApplication", "registration"]) +@pytest.mark.parametrize("filing_name", [ + "incorporationApplication", + "registration", + "amalgamationApplication" +]) def test_create_bootstrap_minimal_draft_filing(client, jwt, filing_name): """Assert that a minimal filing can be used to create a draft filing.""" filing = {"filing": {"header": {"name": filing_name, "accountId": 28}}} @@ -384,9 +388,9 @@ def test_post_affiliated_businesses(session, client, jwt): (identifiers[1], LegalEntity.EntityTypes.BCOMP.value, "123456789BC0001"), ] draft_businesses = [ - (identifiers[2], LegalEntity.EntityTypes.BCOMP.value, None), - (identifiers[3], LegalEntity.EntityTypes.SOLE_PROP.value, "NR 1234567"), - (identifiers[4], LegalEntity.EntityTypes.BCOMP.value, None), + (identifiers[2], "registration", LegalEntity.EntityTypes.GP.value, None), + (identifiers[3], "incorporationApplication", LegalEntity.EntityTypes.SOLE_PROP.value, "NR 1234567"), + (identifiers[4], "amalgamationApplication", LegalEntity.EntityTypes.COMP.value, "NR 1234567") ] # NB: these are real businesses now so temp should not get returned @@ -406,20 +410,21 @@ def test_post_affiliated_businesses(session, client, jwt): ) for draft_business in draft_businesses: - filing_name = ( - "incorporationApplication" - if draft_business[1] == LegalEntity.EntityTypes.BCOMP.value - else "registration" - ) + filing_name = draft_business[1] temp_reg = RegistrationBootstrap() temp_reg._identifier = draft_business[0] temp_reg.save() json_data = copy.deepcopy(FILING_HEADER) json_data["filing"]["header"]["name"] = filing_name json_data["filing"]["header"]["identifier"] = draft_business[0] - json_data["filing"]["header"]["legalType"] = draft_business[1] - if draft_business[2]: - json_data["filing"][filing_name] = {"nameRequest": {"nrNumber": draft_business[2]}} + json_data["filing"]["header"]["legalType"] = draft_business[2] + if draft_business[3]: + json_data["filing"][filing_name] = {"nameRequest": {"nrNumber": draft_business[3]}} + if filing_name == "amalgamationApplication": + json_data["filing"][filing_name] = { + **json_data["filing"][filing_name], + "type": "regular" + } filing = factory_pending_filing(None, json_data) filing.temp_reg = draft_business[0] if draft_business[0] in old_draft_businesses: diff --git a/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py b/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py index 7e877e6047..d6ae6aca83 100644 --- a/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py +++ b/legal-api/tests/unit/resources/v2/test_business_digital_credentials.py @@ -20,8 +20,8 @@ from http import HTTPStatus from unittest.mock import patch -from legal_api.models import DCDefinition from legal_api.services.authz import BASIC_USER +from legal_api.models import DCDefinition, User from legal_api.services.digital_credentials import DigitalCredentialsService from tests.unit import nested_session from tests.unit.models import factory_legal_entity @@ -33,92 +33,85 @@ content_type = "application/json" -def test_create_invitation(session, client, jwt): # pylint:disable=unused-argument +@patch("legal_api.decorators.are_digital_credentials_allowed", return_value=True) +def test_create_invitation(app, session, client, jwt): # pylint:disable=unused-argument """Assert create invitation endpoint returns invitation_url.""" with nested_session(session): headers = create_header(jwt, [BASIC_USER]) identifier = "FM1234567" factory_legal_entity(identifier) - connection_id = "0d94e18b-3a52-4122-8adf-33e2ccff681f" - invitation_url = """http://192.168.65.3:8020?c_i=eyJAdHlwZSI6ICJodHRwczovL2RpZGNvbW0ub3JnL2Nvbm5lY3Rpb + invitation_id = "0d94e18b-3a52-4122-8adf-33e2ccff681f" + invitation_url = """http://192.168.65.3:8020?c_i=eyJAdHlwZSI6ICJodHRwczovL2RpZGNvbW0ub3JnL2Nvbm5lY3Rpb 25zLzEuMC9pbnZpdGF0aW9uIiwgIkBpZCI6ICIyZjU1M2JkZS01YWJlLTRkZDctODIwZi1mNWQ2Mjc1OWQxODgi LCAicmVjaXBpZW50S2V5cyI6IFsiMkFHSjVrRDlVYU45OVpSeUFHZVZKNDkxclZhNzZwZGZYdkxXZkFyc2lKWjY iXSwgImxhYmVsIjogImZhYmVyLmFnZW50IiwgInNlcnZpY2VFbmRwb2ludCI6ICJodHRwOi8vMTkyLjE2OC42NS4zOjgwMjAifQ==""" - with patch.object( - DigitalCredentialsService, - "create_invitation", - return_value={"connection_id": connection_id, "invitation_url": invitation_url}, - ): - rv = client.post( - f"/api/v2/businesses/{identifier}/digitalCredentials/invitation", - headers=headers, - content_type=content_type, - ) - assert rv.status_code == HTTPStatus.OK - assert rv.json.get("invitationUrl") == invitation_url - - -def test_get_connection_not_found(session, client, jwt): # pylint:disable=unused-argument - """Assert get connection endpoint returns not found when there is no active connection.""" - with nested_session(session): - headers = create_header(jwt, [BASIC_USER]) - identifier = "FM1234567" - legal_entity = factory_legal_entity(identifier) - create_dc_connection(legal_entity) + with patch.object(DigitalCredentialsService, "create_invitation", return_value={ + "invitation": {"@id": invitation_id}, "invitation_url": invitation_url}): - rv = client.get( - f"/api/v2/businesses/{identifier}/digitalCredentials/connection", headers=headers, content_type=content_type - ) - assert rv.status_code == HTTPStatus.NOT_FOUND - assert rv.json.get("message") == "No active connection found." + rv = client.post(f"/api/v2/businesses/{identifier}/digitalCredentials/invitation", + headers=headers, content_type=content_type) + assert rv.status_code == HTTPStatus.OK + assert rv.json.get("invitationUrl") == invitation_url -def test_get_connection(session, client, jwt): # pylint:disable=unused-argument - """Assert get connection endpoint returns connection json.""" - with nested_session(session): - headers = create_header(jwt, [BASIC_USER]) - identifier = "FM1234567" - legal_entity = factory_legal_entity(identifier) +@patch("legal_api.decorators.are_digital_credentials_allowed", return_value=True) +def test_get_connections_not_found(app, session, client, jwt): # pylint:disable=unused-argument + """Assert get connections endpoint returns not found when there is no active connection.""" + headers = create_header(jwt, [BASIC_USER]) + identifier = "FM1234567" + factory_legal_entity(identifier) - connection = create_dc_connection(legal_entity, is_active=True) - - rv = client.get( - f"/api/v2/businesses/{identifier}/digitalCredentials/connection", headers=headers, content_type=content_type - ) - assert rv.status_code == HTTPStatus.OK - assert rv.json.get("invitationUrl") == connection.invitation_url - assert rv.json.get("connectionId") == connection.connection_id - assert rv.json.get("isActive") == connection.is_active - assert rv.json.get("connectionState") == connection.connection_state + rv = client.get(f"/api/v2/businesses/{identifier}/digitalCredentials/connections", + headers=headers, content_type=content_type) + assert rv.status_code == HTTPStatus.OK + assert rv.json.get("connections") == [] -def test_send_credential(session, client, jwt): # pylint:disable=unused-argument - """Assert Issue credentials to the connection.""" +@patch("legal_api.decorators.are_digital_credentials_allowed", return_value=True) +def test_get_connections(app, session, client, jwt): # pylint:disable=unused-argument + """Assert get connection endpoint returns connection json.""" with nested_session(session): headers = create_header(jwt, [BASIC_USER]) identifier = "FM1234567" legal_entity = factory_legal_entity(identifier) - create_dc_definition() - create_dc_connection(legal_entity, is_active=True) + connection = create_dc_connection(legal_entity, is_active=True) - with patch.object( - DigitalCredentialsService, - "issue_credential", - return_value={"credential_exchange_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"}, - ): - rv = client.post( - f"/api/v2/businesses/{identifier}/digitalCredentials/{DCDefinition.CredentialType.business.name}", - headers=headers, - content_type=content_type, - ) - assert rv.status_code == HTTPStatus.OK - assert rv.json.get("message") == "Issue Credential is initiated." + rv = client.get(f"/api/v2/businesses/{identifier}/digitalCredentials/connections", + headers=headers, content_type=content_type) + assert rv.status_code == HTTPStatus.OK + assert rv.json.get("connections")[0].get("invitationUrl") == connection.invitation_url + assert rv.json.get("connections")[0].get("connectionId") == connection.connection_id + assert rv.json.get("connections")[0].get("isActive") == connection.is_active + assert rv.json.get("connections")[0].get("connectionState") == connection.connection_state -def test_get_issued_credentials(session, client, jwt): # pylint:disable=unused-argument +@patch("legal_api.decorators.are_digital_credentials_allowed", return_value=True) +def test_send_credential(app, session, client, jwt): # pylint:disable=unused-argument + """Assert Issue credentials to the connection.""" + headers = create_header(jwt, [BASIC_USER]) + identifier = "FM1234567" + legal_entity = factory_legal_entity(identifier) + definition = create_dc_definition() + test_user = User(username="test-user", firstname="test", lastname="test") + test_user.save() + create_dc_connection(legal_entity, is_active=True) + cred_ex_id = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + + with patch.object(User, "find_by_jwt_token", return_value=test_user): + with patch.object(DCDefinition, "find_by", return_value=definition): + with patch.object(DigitalCredentialsService, "issue_credential", return_value={"cred_ex_id": cred_ex_id}): + rv = client.post( + f"/api/v2/businesses/{identifier}/digitalCredentials/{DCDefinition.CredentialType.business.name}", + headers=headers, content_type=content_type) + assert rv.status_code == HTTPStatus.OK + assert rv.json.get("credentialExchangeId") == cred_ex_id + + +@patch("legal_api.decorators.are_digital_credentials_allowed", return_value=True) +def test_get_issued_credentials(app, session, client, jwt): # pylint:disable=unused-argument """Assert Get all issued credentials json.""" with nested_session(session): headers = create_header(jwt, [BASIC_USER]) @@ -140,7 +133,8 @@ def test_get_issued_credentials(session, client, jwt): # pylint:disable=unused- assert not rv.json.get("issuedCredentials")[0].get("isRevoked") -def test_webhook_connections_notification(session, client, jwt): # pylint:disable=unused-argument +@patch("legal_api.decorators.are_digital_credentials_allowed", return_value=True) +def test_webhook_connections_notification(app, session, client, jwt): # pylint:disable=unused-argument """Assert webhook connection notification endpoint when connection to active.""" with nested_session(session): headers = create_header(jwt, [BASIC_USER]) @@ -149,43 +143,45 @@ def test_webhook_connections_notification(session, client, jwt): # pylint:disab connection = create_dc_connection(legal_entity) - json_data = {"connection_id": connection.connection_id, "state": "active"} - rv = client.post( - "/api/v2/digitalCredentials/topic/connections", json=json_data, headers=headers, content_type=content_type - ) - assert rv.status_code == HTTPStatus.OK - - rv = client.get( - f"/api/v2/businesses/{identifier}/digitalCredentials/connection", headers=headers, content_type=content_type - ) - assert rv.status_code == HTTPStatus.OK - assert rv.json.get("isActive") == connection.is_active - assert rv.json.get("connectionState") == connection.connection_state - - -def test_webhook_issue_credential_notification(session, client, jwt): # pylint:disable=unused-argument + json_data = { + "invitation": {"@id": connection.connection_id}, + "connection_id": connection.connection_id, + "state": "active" + } + rv = client.post("/api/v2/digitalCredentials/topic/connections", + json=json_data, + headers=headers, content_type=content_type) + assert rv.status_code == HTTPStatus.OK + + rv = client.get(f"/api/v2/businesses/{identifier}/digitalCredentials/connections", + headers=headers, content_type=content_type) + assert rv.status_code == HTTPStatus.OK + assert rv.json.get("connections")[0].get("isActive") == connection.is_active + assert rv.json.get("connections")[0].get("connectionState") == connection.connection_state + + +@patch("legal_api.decorators.are_digital_credentials_allowed", return_value=True) +def test_webhook_issue_credential_notification(app, session, client, jwt): # pylint:disable=unused-argument """Assert webhook issue_credential notification endpoint when credential issued.""" - with nested_session(session): - headers = create_header(jwt, [BASIC_USER]) - identifier = "FM1234567" - legal_entity = factory_legal_entity(identifier) - - issued_credential = create_dc_issued_credential(legal_entity=legal_entity) - - json_data = {"credential_exchange_id": issued_credential.credential_exchange_id, "state": "credential_issued"} - rv = client.post( - "/api/v2/digitalCredentials/topic/issue_credential", - json=json_data, - headers=headers, - content_type=content_type, - ) - assert rv.status_code == HTTPStatus.OK - - rv = client.get( - f"/api/v2/businesses/{identifier}/digitalCredentials", headers=headers, content_type=content_type - ) - assert rv.status_code == HTTPStatus.OK - assert len(rv.json.get("issuedCredentials")) == 1 - assert rv.json.get("issuedCredentials")[0].get("isIssued") - assert rv.json.get("issuedCredentials")[0].get("dateOfIssue") - assert not rv.json.get("issuedCredentials")[0].get("isRevoked") + headers = create_header(jwt, [BASIC_USER]) + identifier = "FM1234567" + legal_entity = factory_legal_entity(identifier) + + issued_credential = create_dc_issued_credential(legal_entity=legal_entity) + + json_data = { + "cred_ex_id": issued_credential.credential_exchange_id, + "state": "done" + } + rv = client.post("/api/v2/digitalCredentials/topic/issue_credential_v2_0", + json=json_data, + headers=headers, content_type=content_type) + assert rv.status_code == HTTPStatus.OK + + rv = client.get(f"/api/v2/businesses/{identifier}/digitalCredentials", + headers=headers, content_type=content_type) + assert rv.status_code == HTTPStatus.OK + assert len(rv.json.get("issuedCredentials")) == 1 + assert rv.json.get("issuedCredentials")[0].get("isIssued") + assert rv.json.get("issuedCredentials")[0].get("dateOfIssue") + assert not rv.json.get("issuedCredentials")[0].get("isRevoked") diff --git a/legal-api/tests/unit/resources/v2/test_business_director.py b/legal-api/tests/unit/resources/v2/test_business_director.py index eeb90a9939..99987ed769 100644 --- a/legal-api/tests/unit/resources/v2/test_business_director.py +++ b/legal-api/tests/unit/resources/v2/test_business_director.py @@ -262,37 +262,6 @@ def test_directors_mailing_address(session, client, jwt): assert rv.json["directors"][0]["mailingAddress"]["addressCity"] == "Test Mailing City" -def test_directors_coop_no_mailing_address(session, client, jwt): - """Assert that coop directors have a mailing and delivery address.""" - with nested_session(session): - # setup - identifier = "CP7654321" - legal_entity = factory_legal_entity(identifier) - delivery_address = Address(city="Test Delivery City", address_type=Address.DELIVERY) - officer = { - "firstName": "Michael", - "lastName": "Crane", - "middleInitial": "Joe", - "partyType": "person", - "organizationName": "", - } - party_role = factory_party_role( - delivery_address, None, officer, datetime.datetime(2017, 5, 17), None, EntityRole.RoleTypes.director - ) - legal_entity.entity_roles.append(party_role) - legal_entity.save() - - # test - rv = client.get( - f"/api/v2/businesses/{identifier}/directors", headers=create_header(jwt, [STAFF_ROLE], identifier) - ) - # check - assert rv.status_code == HTTPStatus.OK - assert "directors" in rv.json - assert rv.json["directors"][0]["deliveryAddress"]["addressCity"] == "Test Delivery City" - assert "mailingAddress" not in rv.json["directors"][0] - - def test_directors_unauthorized(app, session, client, jwt, requests_mock): """Assert that directors are not returned for an unauthorized user.""" with nested_session(session): diff --git a/legal-api/tests/unit/resources/v2/test_business_filings/test_filing_documents.py b/legal-api/tests/unit/resources/v2/test_business_filings/test_filing_documents.py index 91e1389240..a257b65c23 100644 --- a/legal-api/tests/unit/resources/v2/test_business_filings/test_filing_documents.py +++ b/legal-api/tests/unit/resources/v2/test_business_filings/test_filing_documents.py @@ -29,6 +29,9 @@ from flask import current_app from registry_schemas.example_data import ( ALTERATION_FILING_TEMPLATE, + AGM_EXTENSION, + AGM_LOCATION_CHANGE, + AMALGAMATION_APPLICATION, ANNUAL_REPORT, CHANGE_OF_ADDRESS, CHANGE_OF_DIRECTORS, @@ -1510,6 +1513,122 @@ def test_unpaid_filing(session, client, jwt): HTTPStatus.OK, "2017-10-01", ), + ( + 'ben_agmExtension_completed', + 'BC7654321', + LegalEntity.EntityTypes.BCOMP.value, + 'agmExtension', + AGM_EXTENSION, + None, + None, + Filing.Status.COMPLETED, + { + 'documents': { + 'letterOfAgmExtension': 'https://LEGAL_API_BASE_URL/api/v2/businesses/BC7654321/filings/documents/letterOfAgmExtension', + 'receipt': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/receipt' + } + }, + HTTPStatus.OK, + '2017-10-01' + ), + ( + 'ben_agmLocationChange_paid', + 'BC7654321', + LegalEntity.EntityTypes.BCOMP.value, + 'agmExtension', + AGM_EXTENSION, + None, + None, + Filing.Status.PAID, + { + 'documents': { + 'receipt': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/receipt' + } + }, + HTTPStatus.OK, + '2017-10-01' + ), + ( + 'ben_agmLocationChange_completed', + 'BC7654321', + LegalEntity.EntityTypes.BCOMP.value, + 'agmLocationChange', + AGM_LOCATION_CHANGE, + None, + None, + Filing.Status.COMPLETED, + { + 'documents': { + 'letterOfAgmLocationChange': 'https://LEGAL_API_BASE_URL/api/v2/businesses/BC7654321/filings/documents/letterOfAgmLocationChange', + 'receipt': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/receipt' + } + }, + HTTPStatus.OK, + '2017-10-01' + ), + ( + 'ben_agmLocationChange_paid', + 'BC7654321', + LegalEntity.EntityTypes.BCOMP.value, + 'agmLocationChange', + AGM_LOCATION_CHANGE, + None, + None, + Filing.Status.PAID, + { + 'documents': { + 'receipt': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/receipt' + } + }, + HTTPStatus.OK, + '2017-10-01' + ), + ( + 'ben_amalgamation_completed', + 'BC7654321', + LegalEntity.EntityTypes.BCOMP.value, + 'amalgamationApplication', + AMALGAMATION_APPLICATION, + None, + None, + Filing.Status.COMPLETED, + { + 'documents': { + 'certificateOfAmalgamation': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/certificateOfAmalgamation', + 'legalFilings': [ + { + 'amalgamationApplication': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/amalgamationApplication' + } + ], + 'noticeOfArticles': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/noticeOfArticles', + 'receipt': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/receipt' + } + }, + HTTPStatus.OK, + '2017-10-01' + ), + ( + 'ben_amalgamation_paid', + 'BC7654321', + LegalEntity.EntityTypes.BCOMP.value, + 'amalgamationApplication', + AMALGAMATION_APPLICATION, + None, + None, + Filing.Status.PAID, + { + 'documents': { + 'legalFilings': [ + { + 'amalgamationApplication': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/amalgamationApplication' + } + ], + 'receipt': f'{base_url}/api/v2/businesses/BC7654321/filings/1/documents/receipt' + } + }, + HTTPStatus.OK, + '2017-10-01' + ), ( "ben_changeOfAddress", "BC7654321", @@ -1949,6 +2068,36 @@ def test_document_list_for_various_filing_states( assert rv_data == expected +def filer_action(filing_name, filing_json, meta_data, business): + """Helper function for test_document_list_for_various_filing_states.""" + if filing_name == 'alteration' and \ + (legal_name := filing_json['filing']['alteration'].get('nameRequest', {}).get('legalName')): + meta_data['alteration'] = {} + meta_data['alteration']['fromLegalName'] = business.legal_name + meta_data['alteration']['toLegalName'] = legal_name + + if filing_name == 'correction' and business.legal_type == 'CP': + meta_data['correction'] = {} + if (legal_name := filing_json['filing']['correction'].get('nameRequest', {}).get('legalName')): + meta_data['correction']['fromLegalName'] = business.legal_name + meta_data['correction']['toLegalName'] = legal_name + + if filing_json['filing']['correction'].get('rulesFileKey'): + meta_data['correction']['uploadNewRules'] = True + + if filing_json['filing']['correction'].get('memorandumFileKey'): + meta_data['correction']['uploadNewMemorandum'] = True + + if filing_json['filing']['correction'].get('resolution'): + meta_data['correction']['hasResolution'] = True + + if filing_name == 'specialResolution' and business.legal_type == 'CP': + meta_data['alteration'] = {} + meta_data['alteration']['uploadNewRules'] = True + + return meta_data + + def test_get_receipt(session, client, jwt, requests_mock): """Assert that a receipt is generated.""" with nested_session(session): diff --git a/legal-api/tests/unit/resources/v2/test_business_filings/test_filings.py b/legal-api/tests/unit/resources/v2/test_business_filings/test_filings.py index 1acc1794ff..cdfae320de 100644 --- a/legal-api/tests/unit/resources/v2/test_business_filings/test_filings.py +++ b/legal-api/tests/unit/resources/v2/test_business_filings/test_filings.py @@ -1100,6 +1100,14 @@ def test_calc_annual_report_date(session, client, jwt): CONTINUATION_OUT_FILING["filing"]["continuationOut"] = {} +# FUTURE: use AGM_LOCATION_CHANGE_FILING from business schema data when AGM location change filing work has been done +AGM_LOCATION_CHANGE_FILING = copy.deepcopy(FILING_HEADER) +AGM_LOCATION_CHANGE_FILING["filing"]["agmLocationChange"] = {} + +# FUTURE: use AGM_EXTENSION_FILING from business schema data when AGM Extension filing work has been done +AGM_EXTENSION_FILING = copy.deepcopy(FILING_HEADER) +AGM_EXTENSION_FILING["filing"]["agmExtension"] = {} + def _get_expected_fee_code(free, filing_name, filing_json: dict, legal_type): """Return fee codes for legal type.""" filing_sub_type = Filing.get_filings_sub_type(filing_name, filing_json) @@ -1114,119 +1122,81 @@ def _get_expected_fee_code(free, filing_name, filing_json: dict, legal_type): return Filing.FILINGS[filing_name].get("codes", {}).get(legal_type) - @pytest.mark.parametrize( - "identifier, base_filing, filing_name, orig_legal_type, free, additional_fee_codes", + "identifier, base_filing, filing_name, orig_legal_type, free, additional_fee_codes, has_fed", [ - ("BC1234567", ALTERATION_FILING_TEMPLATE, "alteration", LegalEntity.EntityTypes.COMP.value, False, []), - ("BC1234568", ALTERATION_FILING_TEMPLATE, "alteration", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("BC1234567", TRANSITION_FILING_TEMPLATE, "transition", LegalEntity.EntityTypes.COMP.value, False, []), - ("BC1234568", TRANSITION_FILING_TEMPLATE, "transition", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("BC1234569", ANNUAL_REPORT, "annualReport", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("BC1234569", FILING_HEADER, "changeOfAddress", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("BC1234569", FILING_HEADER, "changeOfDirectors", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("BC1234569", FILING_HEADER, "changeOfDirectors", LegalEntity.EntityTypes.BCOMP.value, True, []), - ("BC1234569", CORRECTION_INCORPORATION, "correction", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("CP1234567", ANNUAL_REPORT, "annualReport", LegalEntity.EntityTypes.COOP.value, False, []), - ("CP1234567", FILING_HEADER, "changeOfAddress", LegalEntity.EntityTypes.COOP.value, False, []), - ("CP1234567", FILING_HEADER, "changeOfDirectors", LegalEntity.EntityTypes.COOP.value, False, []), - ("CP1234567", CORRECTION_AR, "correction", LegalEntity.EntityTypes.COOP.value, False, []), - ("CP1234567", FILING_HEADER, "changeOfDirectors", LegalEntity.EntityTypes.COOP.value, True, []), - ( - "T1234567", - INCORPORATION_FILING_TEMPLATE, - "incorporationApplication", - LegalEntity.EntityTypes.BCOMP.value, - False, - [], - ), - ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.COMP.value, False, []), - ( - "CP1234567", - DISSOLUTION_VOLUNTARY_FILING, - "dissolution", - LegalEntity.EntityTypes.COOP.value, - False, - ["AFDVT", "SPRLN"], - ), - ( - "BC1234567", - DISSOLUTION_VOLUNTARY_FILING, - "dissolution", - LegalEntity.EntityTypes.BC_ULC_COMPANY.value, - False, - [], - ), - ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.BC_CCC.value, False, []), - ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.LIMITED_CO.value, False, []), - ("BC1234567", RESTORATION_FULL_FILING, "restoration", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("BC1234567", RESTORATION_FULL_FILING, "restoration", LegalEntity.EntityTypes.COMP.value, False, []), - ("BC1234567", RESTORATION_FULL_FILING, "restoration", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, False, []), - ("BC1234567", RESTORATION_FULL_FILING, "restoration", LegalEntity.EntityTypes.BC_CCC.value, False, []), - ("BC1234567", RESTORATION_LIMITED_FILING, "restoration", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("BC1234567", RESTORATION_LIMITED_FILING, "restoration", LegalEntity.EntityTypes.COMP.value, False, []), - ( - "BC1234567", - RESTORATION_LIMITED_FILING, - "restoration", - LegalEntity.EntityTypes.BC_ULC_COMPANY.value, - False, - [], - ), - ("BC1234567", RESTORATION_LIMITED_FILING, "restoration", LegalEntity.EntityTypes.BC_CCC.value, False, []), - ("BC1234567", RESTORATION_LIMITED_EXT_FILING, "restoration", LegalEntity.EntityTypes.BCOMP.value, False, []), - ("BC1234567", RESTORATION_LIMITED_EXT_FILING, "restoration", LegalEntity.EntityTypes.COMP.value, False, []), - ( - "BC1234567", - RESTORATION_LIMITED_EXT_FILING, - "restoration", - LegalEntity.EntityTypes.BC_ULC_COMPANY.value, - False, - [], - ), - ("BC1234567", RESTORATION_LIMITED_EXT_FILING, "restoration", LegalEntity.EntityTypes.BC_CCC.value, False, []), - ( - "BC1234567", - RESTORATION_LIMITED_TO_FULL_FILING, - "restoration", - LegalEntity.EntityTypes.BCOMP.value, - False, - [], - ), - ("BC1234567", RESTORATION_LIMITED_TO_FULL_FILING, "restoration", LegalEntity.EntityTypes.COMP.value, False, []), - ( - "BC1234567", - RESTORATION_LIMITED_TO_FULL_FILING, - "restoration", - LegalEntity.EntityTypes.BC_ULC_COMPANY.value, - False, - [], - ), - ( - "BC1234567", - RESTORATION_LIMITED_TO_FULL_FILING, - "restoration", - LegalEntity.EntityTypes.BC_CCC.value, - False, - [], - ), - ("BC1234567", CONTINUATION_OUT_FILING, "continuationOut", LegalEntity.EntityTypes.BCOMP.value, False, []), - ( - "BC1234567", - CONTINUATION_OUT_FILING, - "continuationOut", - LegalEntity.EntityTypes.BC_ULC_COMPANY.value, - False, - [], - ), - ("BC1234567", CONTINUATION_OUT_FILING, "continuationOut", LegalEntity.EntityTypes.COMP.value, False, []), - ("BC1234567", CONTINUATION_OUT_FILING, "continuationOut", LegalEntity.EntityTypes.BC_CCC.value, False, []), - ], + ("BC1234567", ALTERATION_FILING_TEMPLATE, "alteration", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("BC1234568", ALTERATION_FILING_TEMPLATE, "alteration", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", TRANSITION_FILING_TEMPLATE, "transition", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("BC1234568", TRANSITION_FILING_TEMPLATE, "transition", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234569", ANNUAL_REPORT, "annualReport", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234569", FILING_HEADER, "changeOfAddress", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234569", FILING_HEADER, "changeOfDirectors", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234569", FILING_HEADER, "changeOfDirectors", LegalEntity.EntityTypes.BCOMP.value, True, [], False), + ("BC1234569", CORRECTION_INCORPORATION, "correction", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("CP1234567", ANNUAL_REPORT, "annualReport", LegalEntity.EntityTypes.COOP.value, False, [], False), + ("CP1234567", FILING_HEADER, "changeOfAddress", LegalEntity.EntityTypes.COOP.value, False, [], False), + ("CP1234567", FILING_HEADER, "changeOfDirectors", LegalEntity.EntityTypes.COOP.value, False, [], False), + ("CP1234567", CORRECTION_AR, "correction", LegalEntity.EntityTypes.COOP.value, False, [], False), + ("CP1234567", FILING_HEADER, "changeOfDirectors", LegalEntity.EntityTypes.COOP.value, True, [], False), + ("T1234567", INCORPORATION_FILING_TEMPLATE, "incorporationApplication", + LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("CP1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.COOP.value, False, + ["AFDVT", "SPRLN"], False), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, + False, [], False), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.BC_CCC.value, + False, [], False), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.LIMITED_CO.value, + False, [], False), + ("BC1234567", RESTORATION_FULL_FILING, "restoration", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", RESTORATION_FULL_FILING, "restoration", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("BC1234567", RESTORATION_FULL_FILING, "restoration", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, False, [], False), + ("BC1234567", RESTORATION_FULL_FILING, "restoration", LegalEntity.EntityTypes.BC_CCC.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_FILING, "restoration", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_FILING, "restoration", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_FILING, "restoration", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_FILING, "restoration", LegalEntity.EntityTypes.BC_CCC.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_EXT_FILING, "restoration", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_EXT_FILING, "restoration", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_EXT_FILING, "restoration", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_EXT_FILING, "restoration", LegalEntity.EntityTypes.BC_CCC.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_TO_FULL_FILING, "restoration", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_TO_FULL_FILING, "restoration", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_TO_FULL_FILING, "restoration", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, False, [], False), + ("BC1234567", RESTORATION_LIMITED_TO_FULL_FILING, "restoration", LegalEntity.EntityTypes.BC_CCC.value, False, [], False), + ("BC1234567", CONTINUATION_OUT_FILING, "continuationOut", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", CONTINUATION_OUT_FILING, "continuationOut", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, False, [], False), + ("BC1234567", CONTINUATION_OUT_FILING, "continuationOut", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("BC1234567", CONTINUATION_OUT_FILING, "continuationOut", LegalEntity.EntityTypes.BC_CCC.value, False, [], False), + ("BC1234567", AGM_LOCATION_CHANGE_FILING, "agmLocationChange", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", AGM_LOCATION_CHANGE_FILING, "agmLocationChange", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, False, [], False), + ("BC1234567", AGM_LOCATION_CHANGE_FILING, "agmLocationChange", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("BC1234567", AGM_LOCATION_CHANGE_FILING, "agmLocationChange", LegalEntity.EntityTypes.BC_CCC.value, False, [], False), + ("BC1234567", AGM_EXTENSION_FILING, "agmExtension", LegalEntity.EntityTypes.BCOMP.value, False, [], False), + ("BC1234567", AGM_EXTENSION_FILING, "agmExtension", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, False, [], False), + ("BC1234567", AGM_EXTENSION_FILING, "agmExtension", LegalEntity.EntityTypes.COMP.value, False, [], False), + ("BC1234567", AGM_EXTENSION_FILING, "agmExtension", LegalEntity.EntityTypes.BC_CCC.value, False, [], False), + ("BC1234567", ALTERATION_FILING_TEMPLATE, "alteration", LegalEntity.EntityTypes.COMP.value, False, [], True), + ("BC1234568", ALTERATION_FILING_TEMPLATE, "alteration", LegalEntity.EntityTypes.BCOMP.value, False, [], True), + ("T1234567", INCORPORATION_FILING_TEMPLATE, "incorporationApplication", + LegalEntity.EntityTypes.BCOMP.value, False, [], True), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.BCOMP.value, False, [], True), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.COMP.value, False, [], True), + ("CP1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.COOP.value, False, + ["AFDVT", "SPRLN"], True), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.BC_ULC_COMPANY.value, + False, [], True), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.BC_CCC.value, + False, [], True), + ("BC1234567", DISSOLUTION_VOLUNTARY_FILING, "dissolution", LegalEntity.EntityTypes.LIMITED_CO.value, + False, [], True), + ] ) def test_get_correct_fee_codes( - session, identifier, base_filing, filing_name, orig_legal_type, free, additional_fee_codes -): + session, identifier, base_filing, filing_name, orig_legal_type, free, additional_fee_codes, has_fed): """Assert fee codes are properly assigned to filings before sending to payment.""" with nested_session(session): # setup @@ -1241,6 +1211,9 @@ def test_get_correct_fee_codes( filing["filing"]["business"]["legalType"] = orig_legal_type filing["filing"]["header"]["name"] = filing_name + if has_fed: + filing["filing"]["header"]["effectiveDate"] = "2999-01-01T00:00:00+00:00" + if filing_name == "alteration": filing["filing"][filing_name]["business"]["legalType"] = orig_legal_type elif filing_name == "transition": @@ -1258,16 +1231,24 @@ def test_get_correct_fee_codes( filing["filing"]["changeOfDirectors"]["directors"][0]["actions"] = ["ceased", "nameChanged"] filing["filing"]["changeOfDirectors"]["directors"][1]["actions"] = ["nameChanged", "addressChanged"] - # get fee code - fee_code = ListFilingResource._get_filing_types(legal_entity, filing)[0]["filingTypeCode"] + # get fee code and future effective date + filing_type = ListFilingResource.get_filing_types(legal_entity, filing)[0] + fee_code = filing_type["filingTypeCode"] + future_effective = filing_type.get("futureEffective") - # verify fee code + # verify fee code and future effective date assert fee_code == expected_fee_code + if has_fed: + assert future_effective is True + else: + if filing_name in ["incorporationApplication", "alteration", "dissolution"]: + assert future_effective is False + else: + assert future_effective is None - assert all( - elem in map(lambda x: x["filingTypeCode"], ListFilingResource._get_filing_types(legal_entity, filing)) - for elem in additional_fee_codes - ) + assert all(elem in + map(lambda x: x["filingTypeCode"], ListFilingResource.get_filing_types(legal_entity, filing)) + for elem in additional_fee_codes) @integration_payment @@ -1286,7 +1267,6 @@ def test_coa_future_effective(session, client, jwt): f"/api/v2/businesses/{identifier}/filings", json=coa, headers=create_header(jwt, [STAFF_ROLE], identifier) ) assert rv.status_code == HTTPStatus.CREATED - # assert 'effectiveDate' not in rv.json['filing']['header'] identifier = "CP7654321" bc = factory_legal_entity( diff --git a/legal-api/tests/unit/resources/v2/test_business_filings/test_filings_ledger.py b/legal-api/tests/unit/resources/v2/test_business_filings/test_filings_ledger.py index ebfa41db2d..addb51a1a5 100644 --- a/legal-api/tests/unit/resources/v2/test_business_filings/test_filings_ledger.py +++ b/legal-api/tests/unit/resources/v2/test_business_filings/test_filings_ledger.py @@ -62,6 +62,8 @@ ) from tests.unit.services.utils import create_header +REGISTER_CORRECTION_APPLICATION = 'Register Correction Application' + def test_get_all_business_filings_only_one_in_ledger(session, client, jwt): """Assert that the business info can be received in a valid JSONSchema format.""" @@ -142,7 +144,7 @@ def test_ledger_search(session, client, jwt): alteration = next((f for f in ledger["filings"] if f.get("name") == "alteration"), None) assert alteration - assert 15 == len(alteration.keys()) + assert 16 == len(alteration.keys()) assert "availableOnPaperOnly" in alteration assert "effectiveDate" in alteration assert "filingId" in alteration @@ -151,6 +153,7 @@ def test_ledger_search(session, client, jwt): assert "status" in alteration assert "submittedDate" in alteration assert "submitter" in alteration + assert "displayLedger" in alteration # assert alteration['commentsLink'] # assert alteration['correctionLink'] # assert alteration['filingLink'] @@ -457,7 +460,7 @@ def test_ledger_display_corrected_incorporation(session, client, jwt): assert rv.json["filings"] for filing_json in rv.json["filings"]: if filing_json["name"] == "correction": - assert filing_json["displayName"] == "Register Correction Application" + assert filing_json["displayName"] == REGISTER_CORRECTION_APPLICATION elif filing_json["name"] == "incorporationApplication": assert filing_json["displayName"] == "BC Benefit Company Incorporation Application" else: @@ -645,7 +648,7 @@ def test_ledger_display_special_resolution_correction(session, client, jwt): assert rv.json["filings"] for filing_json in rv.json["filings"]: if filing_json["name"] == "correction": - assert filing_json["displayName"] == "Special Resolution Correction" + assert filing_json["displayName"] == REGISTER_CORRECTION_APPLICATION elif filing_json["name"] == "specialResolution": assert filing_json["displayName"] == "Special Resolution" else: @@ -686,7 +689,7 @@ def test_ledger_display_non_special_resolution_correction_name(session, client, assert rv.json["filings"] for filing_json in rv.json["filings"]: if filing_json["name"] == "correction": - assert filing_json["displayName"] == "Register Correction Application" + assert filing_json["displayName"] == REGISTER_CORRECTION_APPLICATION elif filing_json["name"] == "changeOfAddress": assert filing_json["displayName"] == "Address Change" else: diff --git a/legal-api/tests/unit/services/filings/validations/test_agm_extension.py b/legal-api/tests/unit/services/filings/validations/test_agm_extension.py new file mode 100644 index 0000000000..c0e37eae38 --- /dev/null +++ b/legal-api/tests/unit/services/filings/validations/test_agm_extension.py @@ -0,0 +1,78 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test suite to ensure AGM Extension is validated correctly.""" +import copy +from dateutil.relativedelta import relativedelta +from http import HTTPStatus +from unittest.mock import patch + +import pytest +from registry_schemas.example_data import FILING_HEADER + +from legal_api.services.filings.validations.validation import validate +from legal_api.utils.legislation_datetime import LegislationDatetime +from legal_api.utils.datetime import datetime + +from tests.unit.models import factory_legal_entity + + +@pytest.mark.parametrize( + 'test_name, founding_date, agm_ext_json, expected_code, message', + [ + ('SUCCESS_FIRST_AGM_FIRST_EXT', '2023-10-01', + {'year': '2023', 'isFirstAgm': True, 'extReqForAgmYear': False, 'totalApprovedExt': 6, 'extensionDuration': 6}, + None, None), + ('FAIL_FIRST_AGM_FIRST_EXT_TOO_LATE', '2020-10-01', {'year': '2023', 'isFirstAgm': True, 'extReqForAgmYear': False}, + HTTPStatus.BAD_REQUEST, 'Allotted period to request extension has expired.'), + ('SUCCESS_FIRST_AGM_MORE_EXT', '2022-10-01', + {'year': '2023', 'isFirstAgm': True, 'extReqForAgmYear': True, 'expireDateCurrExt': '2024-10-01', 'totalApprovedExt': 12, 'extensionDuration': 6}, + None, None), + ('FAIL_FIRST_AGM_MORE_EXT_TOO_LATE', '2022-10-01', {'year': '2023', 'isFirstAgm': True, 'extReqForAgmYear': True, 'expireDateCurrExt': '2023-12-01'}, + HTTPStatus.BAD_REQUEST, 'Allotted period to request extension has expired.'), + ('FAIL_FIRST_AGM_MORE_EXT_EXCEED_LIMIT', '2021-10-01', {'year': '2023', 'isFirstAgm': True, 'extReqForAgmYear': True, 'expireDateCurrExt': '2024-10-01'}, + HTTPStatus.BAD_REQUEST, 'Company has received the maximum 12 months of allowable extensions.'), + ('SUCCESS_SUBSEQUENT_AGM_FIRST_EXT', '2020-10-01', + {'year': '2023', 'isFirstAgm': False, 'extReqForAgmYear': False, 'prevAgmRefDate': '2023-10-01', 'totalApprovedExt': 6, 'extensionDuration': 6}, + None, None), + ('FAIL_SUBSEQUENT_AGM_FIRST_EXT_TOO_LATE', '2020-10-01', {'year': '2023', 'isFirstAgm': False, 'extReqForAgmYear': False, 'prevAgmRefDate': '2022-06-01'}, + HTTPStatus.BAD_REQUEST, 'Allotted period to request extension has expired.'), + ('SUCCESS_SUBSEQUENT_AGM_MORE_EXT', '2022-10-01', + {'year': '2023', 'isFirstAgm': False, 'extReqForAgmYear': True, 'prevAgmRefDate': '2023-06-01', 'expireDateCurrExt': '2024-05-01', 'totalApprovedExt': 12, 'extensionDuration': 1}, + None, None), + ('FAIL_SUBSEQUENT_AGM_MORE_EXT_TOO_LATE', '2022-10-01', + {'year': '2023', 'isFirstAgm': False, 'extReqForAgmYear': True, 'prevAgmRefDate': '2023-06-01', 'expireDateCurrExt': '2023-12-01'}, + HTTPStatus.BAD_REQUEST, 'Allotted period to request extension has expired.'), + ('FAIL_SUBSEQUENT_AGM_MORE_EXT_EXCEED_LIMIT', '2022-10-01', + {'year': '2023', 'isFirstAgm': False, 'extReqForAgmYear': True, 'prevAgmRefDate': '2023-06-01', 'expireDateCurrExt': '2024-06-01'}, + HTTPStatus.BAD_REQUEST, 'Company has received the maximum 12 months of allowable extensions.') + ] +) +def test_validate_agm_extension(session, mocker, test_name, founding_date, agm_ext_json, expected_code, message): + """Assert validate AGM extension.""" + business = factory_legal_entity( + identifier='BC1234567', entity_type='BC', founding_date=LegislationDatetime.as_legislation_timezone_from_date_str(founding_date) + ) + filing = copy.deepcopy(FILING_HEADER) + filing['filing']['agmExtension'] = agm_ext_json + filing['filing']['header']['name'] = 'agmExtension' + + with patch.object(LegislationDatetime, 'now', return_value=LegislationDatetime.as_legislation_timezone_from_date_str('2024-01-01')): + err = validate(business, filing) + + if not test_name.startswith('SUCCESS'): + assert expected_code == err.code + if message: + assert message == err.msg[0]['error'] + else: + assert not err diff --git a/legal-api/tests/unit/services/filings/validations/test_agm_location_change.py b/legal-api/tests/unit/services/filings/validations/test_agm_location_change.py new file mode 100644 index 0000000000..034b284c50 --- /dev/null +++ b/legal-api/tests/unit/services/filings/validations/test_agm_location_change.py @@ -0,0 +1,66 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test suite to ensure AGM Location Change is validated correctly.""" +import copy +from http import HTTPStatus + +import pytest +from registry_schemas.example_data import AGM_LOCATION_CHANGE, FILING_HEADER + +from legal_api.services.filings.validations.validation import validate +from legal_api.utils.datetime import datetime +from legal_api.utils.legislation_datetime import LegislationDatetime + +from tests.unit.models import factory_legal_entity + + +@pytest.mark.parametrize( + 'test_name, expected_code, message', + [ + ('INVALID_YEAR', HTTPStatus.BAD_REQUEST, 'Invalid AGM year.'), + ('FAIL_YEAR-3', HTTPStatus.BAD_REQUEST, 'AGM year must be between -2 or +1 year from current year.'), + ('FAIL_YEAR+2', HTTPStatus.BAD_REQUEST, 'AGM year must be between -2 or +1 year from current year.'), + ('SUCCESS-2', None, None), + ('SUCCESS+1', None, None), + ('SUCCESS', None, None) + ] +) +def test_validate_agm_year(session, mocker, test_name, expected_code, message): + """Assert validate agm year.""" + business = factory_legal_entity(identifier='BC1234567', entity_type='BC', founding_date=datetime.utcnow()) + filing = copy.deepcopy(FILING_HEADER) + filing['filing']['agmLocationChange'] = copy.deepcopy(AGM_LOCATION_CHANGE) + filing['filing']['header']['name'] = 'agmLocationChange' + + if test_name == 'INVALID_YEAR': + filing['filing']['agmLocationChange']['year'] = 'invalid' + elif test_name == 'FAIL_YEAR-3': + filing['filing']['agmLocationChange']['year'] = str(LegislationDatetime.now().year - 3) + elif test_name == 'FAIL_YEAR+2': + filing['filing']['agmLocationChange']['year'] = str(LegislationDatetime.now().year + 2) + elif test_name == 'SUCCESS-2': + filing['filing']['agmLocationChange']['year'] = str(LegislationDatetime.now().year - 2) + elif test_name == 'SUCCESS+1': + filing['filing']['agmLocationChange']['year'] = str(LegislationDatetime.now().year + 1) + elif test_name == 'SUCCESS': + filing['filing']['agmLocationChange']['year'] = str(LegislationDatetime.now().year) + err = validate(business, filing) + + # validate outcomes + if not test_name.startswith('SUCCESS'): + assert expected_code == err.code + if message: + assert message == err.msg[0]['error'] + else: + assert not err diff --git a/legal-api/tests/unit/services/filings/validations/test_amalgamation_application.py b/legal-api/tests/unit/services/filings/validations/test_amalgamation_application.py new file mode 100644 index 0000000000..6105b9f4f4 --- /dev/null +++ b/legal-api/tests/unit/services/filings/validations/test_amalgamation_application.py @@ -0,0 +1,1111 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Test suite to ensure Amalgamation Application is validated correctly.""" +import copy +from unittest.mock import patch +from http import HTTPStatus + +import pytest +import datetime +from registry_schemas.example_data import AMALGAMATION_APPLICATION + +from legal_api.models import Filing, LegalEntity +from legal_api.services import NameXService, STAFF_ROLE, BASIC_USER +from legal_api.services.filings.validations.validation import validate + +from tests.unit.services.filings.validations import lists_are_equal +from tests.unit.services.utils import helper_create_jwt + + +class MockResponse: + """Mock http response.""" + + def __init__(self, json_data): + """Initialize mock http response.""" + self.json_data = json_data + + def json(self): + """Return mock json data.""" + return self.json_data + + +def _mock_nr_response(legal_type): + return MockResponse({ + 'state': 'APPROVED', + 'legalType': legal_type, + 'expirationDate': '', + 'names': [{ + 'name': AMALGAMATION_APPLICATION['nameRequest']['legalName'], + 'state': 'APPROVED', + 'consumptionDate': '' + }] + }) + + +def test_invalid_nr_amalgamation(mocker, app, session): + """Assert that nr is invalid.""" + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + filing['filing']['amalgamationApplication']['nameRequest']['nrNumber'] = 'NR 1234567' + + invalid_nr_response = { + 'state': 'INPROGRESS', + 'expirationDate': '', + 'names': [{ + 'name': 'legal_name', + 'state': 'INPROGRESS', + 'consumptionDate': '' + }] + } + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_amalgamating_businesses', + return_value=[]) + with patch.object(NameXService, 'query_nr_number', return_value=MockResponse(invalid_nr_response)): + err = validate(None, filing) + + assert err + assert err.msg[0]['error'] == 'Name Request is not approved.' + + +@pytest.mark.parametrize( + 'amalgamation_type, expected_msg', + [ + ('regular', 'At least one Director and a Completing Party is required.'), + ('vertical', 'A Completing Party is required.'), + ('horizontal', 'A Completing Party is required.'), + ] +) +def test_invalid_party(mocker, app, session, amalgamation_type, expected_msg): + """Assert that party is invalid.""" + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + + filing['filing']['amalgamationApplication']['type'] = amalgamation_type + filing['filing']['amalgamationApplication']['parties'] = [] + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_amalgamating_businesses', + return_value=[]) + + err = validate(None, filing) + + assert err + assert err.msg[0]['error'] == expected_msg + + +@pytest.mark.parametrize( + 'test_name, legal_type, delivery_region, delivery_country, mailing_region, mailing_country, expected_code, expected_msg', + [ + ('SUCCESS', LegalEntity.EntityTypes.BCOMP.value, 'BC', 'CA', 'BC', 'CA', None, None), + ('SUCCESS', LegalEntity.EntityTypes.BC_ULC_COMPANY.value, 'BC', 'CA', 'BC', 'CA', None, None), + ('SUCCESS', LegalEntity.EntityTypes.BC_CCC.value, 'BC', 'CA', 'BC', 'CA', None, None), + ('SUCCESS', LegalEntity.EntityTypes.COMP.value, 'BC', 'CA', 'BC', 'CA', None, None), + ('FAIL_NOT_BC_DELIVERY_REGION', LegalEntity.EntityTypes.BCOMP.value, 'AB', 'CA', 'BC', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressRegion'} + ]), + ('FAIL_NOT_BC_DELIVERY_REGION', LegalEntity.EntityTypes.BC_ULC_COMPANY.value, 'AB', 'CA', 'BC', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressRegion'} + ]), + ('FAIL_NOT_BC_DELIVERY_REGION', LegalEntity.EntityTypes.COMP.value, 'AB', 'CA', 'BC', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressRegion'} + ]), + ('FAIL_NOT_BC_DELIVERY_REGION', LegalEntity.EntityTypes.BC_CCC.value, 'AB', 'CA', 'BC', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressRegion'} + ]), + ('FAIL_NOT_BC_MAILING_REGION', LegalEntity.EntityTypes.BCOMP.value, 'BC', 'CA', 'AB', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressRegion'} + ]), + ('FAIL_NOT_BC_MAILING_REGION', LegalEntity.EntityTypes.BCOMP.value, 'BC', 'CA', 'AB', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressRegion'} + ]), + ('FAIL_NOT_BC_MAILING_REGION', LegalEntity.EntityTypes.COMP.value, 'BC', 'CA', 'AB', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressRegion'} + ]), + ('FAIL_NOT_BC_MAILING_REGION', LegalEntity.EntityTypes.BC_ULC_COMPANY.value, 'BC', 'CA', 'AB', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': + '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressRegion'} + ]), + ('FAIL_ALL_ADDRESS_REGIONS', LegalEntity.EntityTypes.BC_CCC.value, 'WA', 'CA', 'WA', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressRegion'}, + {'error': "Address Region must be 'BC'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressRegion'} + ]), + ('FAIL_NOT_DELIVERY_COUNTRY', LegalEntity.EntityTypes.BCOMP.value, 'BC', 'NZ', 'BC', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressCountry'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressCountry'} + ]), + ('FAIL_NOT_DELIVERY_COUNTRY', LegalEntity.EntityTypes.COMP.value, 'BC', 'NZ', 'BC', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressCountry'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressCountry'} + ]), + ('FAIL_NOT_DELIVERY_COUNTRY', LegalEntity.EntityTypes.BC_ULC_COMPANY.value, 'BC', 'NZ', 'BC', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressCountry'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressCountry'} + ]), + ('FAIL_NOT_DELIVERY_COUNTRY', LegalEntity.EntityTypes.BC_CCC.value, 'BC', 'NZ', 'BC', 'CA', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressCountry'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressCountry'} + ]), + ('FAIL_NOT_MAILING_COUNTRY', LegalEntity.EntityTypes.BCOMP.value, 'BC', 'CA', 'BC', 'NZ', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressCountry'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressCountry'} + ]), + ('FAIL_NOT_MAILING_COUNTRY', LegalEntity.EntityTypes.COMP.value, 'BC', 'CA', 'BC', 'NZ', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressCountry'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressCountry'} + ]), + ('FAIL_NOT_MAILING_COUNTRY', LegalEntity.EntityTypes.BC_ULC_COMPANY.value, 'BC', 'CA', 'BC', 'NZ', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressCountry'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressCountry'} + ]), + ('FAIL_NOT_MAILING_COUNTRY', LegalEntity.EntityTypes.BC_CCC.value, 'BC', 'CA', 'BC', 'NZ', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressCountry'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressCountry'} + ]), + ('FAIL_ALL_ADDRESS', LegalEntity.EntityTypes.BCOMP.value, 'AB', 'NZ', 'AB', 'NZ', + HTTPStatus.BAD_REQUEST, [ + {'error': "Address Region must be 'BC'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressRegion'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/deliveryAddress/addressCountry'}, + {'error': "Address Region must be 'BC'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressRegion'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/registeredOffice/mailingAddress/addressCountry'}, + {'error': "Address Region must be 'BC'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressRegion'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/deliveryAddress/addressCountry'}, + {'error': "Address Region must be 'BC'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressRegion'}, + {'error': "Address Country must be 'CA'.", + 'path': '/filing/amalgamationApplication/offices/recordsOffice/mailingAddress/addressCountry'} + ]) + ]) +def test_validate_amalgamation_office(session, mocker, test_name, legal_type, delivery_region, + delivery_country, mailing_region, mailing_country, expected_code, + expected_msg): + """Assert that amalgamation offices can be validated.""" + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + + filing['filing']['amalgamationApplication']['nameRequest'] = {} + filing['filing']['amalgamationApplication']['nameRequest']['nrNumber'] = 'NR 1234567' + filing['filing']['amalgamationApplication']['nameRequest']['legalType'] = legal_type + filing['filing']['amalgamationApplication']['contactPoint']['email'] = 'no_one@never.get' + filing['filing']['amalgamationApplication']['contactPoint']['phone'] = '123-456-7890' + + regoffice = filing['filing']['amalgamationApplication']['offices']['registeredOffice'] + regoffice['deliveryAddress']['addressRegion'] = delivery_region + regoffice['deliveryAddress']['addressCountry'] = delivery_country + regoffice['mailingAddress']['addressRegion'] = mailing_region + regoffice['mailingAddress']['addressCountry'] = mailing_country + + recoffice = filing['filing']['amalgamationApplication']['offices']['recordsOffice'] + recoffice['deliveryAddress']['addressRegion'] = delivery_region + recoffice['deliveryAddress']['addressCountry'] = delivery_country + recoffice['mailingAddress']['addressRegion'] = mailing_region + recoffice['mailingAddress']['addressCountry'] = mailing_country + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_amalgamating_businesses', + return_value=[]) + + err = validate(None, filing) + + # validate outcomes + if expected_code: + assert err.code == expected_code + assert lists_are_equal(err.msg, expected_msg) + else: + assert err is None + + +@pytest.mark.parametrize( + 'test_name, legal_type,' + 'class_name_1,class_has_max_shares,class_max_shares,has_par_value,par_value,currency,' + 'series_name_1,series_has_max_shares,series_max_shares,' + 'class_name_2,series_name_2,' + 'expected_code, expected_msg', + [ + ('SUCCESS', 'BEN', 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, None, None), + ('SUCCESS', 'BEN', 'Share Class 1', False, None, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, None, None), + ('SUCCESS', 'BEN', 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + None, None, None, None), + ('SUCCESS-CLASS2', 'BEN', 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 2', None, None, None), + ('FAIL-CLASS2', 'BEN', + 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 1', None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 name already used in a share class or series.', + 'path': '/filing/amalgamationApplication/shareClasses/1/name/' + }]), + ('FAIL-SERIES2', 'BEN', + 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 2', 'Share Series 1', + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share series Share Series 1 name already used in a share class or series.', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/1' + }]), + ('FAIL_INVALID_CLASS_MAX_SHARES', 'BEN', + 'Share Class 1', True, None, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must provide value for maximum number of shares', + 'path': '/filing/amalgamationApplication/shareClasses/0/maxNumberOfShares/' + }]), + ('FAIL_INVALID_CURRENCY', 'BEN', + 'Share Class 1', True, 5000, True, 0.875, None, 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must specify currency', + 'path': '/filing/amalgamationApplication/shareClasses/0/currency/' + }]), + ('FAIL_INVALID_PAR_VALUE', 'BEN', + 'Share Class 1', True, 5000, True, None, 'CAD', 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must specify par value', + 'path': '/filing/amalgamationApplication/shareClasses/0/parValue/' + }]), + ('FAIL_INVALID_SERIES_MAX_SHARES', 'BEN', + 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, None, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share series Share Series 1 must provide value for maximum number of shares', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/0/maxNumberOfShares' + }]), + ('FAIL_SERIES_SHARES_EXCEEDS_CLASS_SHARES', 'BEN', + 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, 10000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': + 'Series Share Series 1 share quantity must be less than or equal to that of its class Share Class 1', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/0/maxNumberOfShares' + }]), + ('SUCCESS', 'BC', 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, None, None), + ('SUCCESS', 'BC', 'Share Class 1', False, None, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, None, None), + ('SUCCESS', 'BC', 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + None, None, None, None), + ('SUCCESS-CLASS2', 'BC', 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 2', None, None, None), + ('FAIL-CLASS2', 'BC', + 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 1', None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 name already used in a share class or series.', + 'path': '/filing/amalgamationApplication/shareClasses/1/name/' + }]), + ('FAIL-SERIES2', 'BC', + 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 2', 'Share Series 1', + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share series Share Series 1 name already used in a share class or series.', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/1' + }]), + ('FAIL_INVALID_CLASS_MAX_SHARES', 'BC', + 'Share Class 1', True, None, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must provide value for maximum number of shares', + 'path': '/filing/amalgamationApplication/shareClasses/0/maxNumberOfShares/' + }]), + ('FAIL_INVALID_CURRENCY', 'BC', + 'Share Class 1', True, 5000, True, 0.875, None, 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must specify currency', + 'path': '/filing/amalgamationApplication/shareClasses/0/currency/' + }]), + ('FAIL_INVALID_PAR_VALUE', 'BC', + 'Share Class 1', True, 5000, True, None, 'CAD', 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must specify par value', + 'path': '/filing/amalgamationApplication/shareClasses/0/parValue/' + }]), + ('FAIL_INVALID_SERIES_MAX_SHARES', 'BC', + 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, None, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share series Share Series 1 must provide value for maximum number of shares', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/0/maxNumberOfShares' + }]), + ('FAIL_SERIES_SHARES_EXCEEDS_CLASS_SHARES', 'BC', + 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, 10000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': + 'Series Share Series 1 share quantity must be less than or equal to that of its class Share Class 1', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/0/maxNumberOfShares' + }]), + ('SUCCESS', 'ULC', 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, None, None), + ('SUCCESS', 'ULC', 'Share Class 1', False, None, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, None, None), + ('SUCCESS', 'ULC', 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + None, None, None, None), + ('SUCCESS-CLASS2', 'ULC', 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 2', None, None, None), + ('FAIL-CLASS2', 'ULC', + 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 1', None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 name already used in a share class or series.', + 'path': '/filing/amalgamationApplication/shareClasses/1/name/' + }]), + ('FAIL-SERIES2', 'ULC', + 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 2', 'Share Series 1', + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share series Share Series 1 name already used in a share class or series.', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/1' + }]), + ('FAIL_INVALID_CLASS_MAX_SHARES', 'ULC', + 'Share Class 1', True, None, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must provide value for maximum number of shares', + 'path': '/filing/amalgamationApplication/shareClasses/0/maxNumberOfShares/' + }]), + ('FAIL_INVALID_CURRENCY', 'ULC', + 'Share Class 1', True, 5000, True, 0.875, None, 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must specify currency', + 'path': '/filing/amalgamationApplication/shareClasses/0/currency/' + }]), + ('FAIL_INVALID_PAR_VALUE', 'ULC', + 'Share Class 1', True, 5000, True, None, 'CAD', 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must specify par value', + 'path': '/filing/amalgamationApplication/shareClasses/0/parValue/' + }]), + ('FAIL_INVALID_SERIES_MAX_SHARES', 'ULC', + 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, None, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share series Share Series 1 must provide value for maximum number of shares', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/0/maxNumberOfShares' + }]), + ('FAIL_SERIES_SHARES_EXCEEDS_CLASS_SHARES', 'ULC', + 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, 10000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': + 'Series Share Series 1 share quantity must be less than or equal to that of its class Share Class 1', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/0/maxNumberOfShares' + }]), + ('SUCCESS', 'CC', 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, None, None), + ('SUCCESS', 'CC', 'Share Class 1', False, None, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, None, None), + ('SUCCESS', 'CC', 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + None, None, None, None), + ('SUCCESS-CLASS2', 'CC', 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 2', None, None, None), + ('FAIL-CLASS2', 'CC', + 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 1', None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 name already used in a share class or series.', + 'path': '/filing/amalgamationApplication/shareClasses/1/name/' + }]), + ('FAIL-SERIES2', 'CC', + 'Share Class 1', False, None, False, None, None, 'Share Series 1', False, None, + 'Share Class 2', 'Share Series 1', + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share series Share Series 1 name already used in a share class or series.', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/1' + }]), + ('FAIL_INVALID_CLASS_MAX_SHARES', 'CC', + 'Share Class 1', True, None, True, 0.875, 'CAD', 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must provide value for maximum number of shares', + 'path': '/filing/amalgamationApplication/shareClasses/0/maxNumberOfShares/' + }]), + ('FAIL_INVALID_CURRENCY', 'CC', + 'Share Class 1', True, 5000, True, 0.875, None, 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must specify currency', + 'path': '/filing/amalgamationApplication/shareClasses/0/currency/' + }]), + ('FAIL_INVALID_PAR_VALUE', 'CC', + 'Share Class 1', True, 5000, True, None, 'CAD', 'Share Series 1', True, 1000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share class Share Class 1 must specify par value', + 'path': '/filing/amalgamationApplication/shareClasses/0/parValue/' + }]), + ('FAIL_INVALID_SERIES_MAX_SHARES', 'CC', + 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, None, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': 'Share series Share Series 1 must provide value for maximum number of shares', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/0/maxNumberOfShares' + }]), + ('FAIL_SERIES_SHARES_EXCEEDS_CLASS_SHARES', 'CC', + 'Share Class 1', True, 5000, True, 0.875, 'CAD', 'Share Series 1', True, 10000, + None, None, + HTTPStatus.BAD_REQUEST, [{ + 'error': + 'Series Share Series 1 share quantity must be less than or equal to that of its class Share Class 1', + 'path': '/filing/amalgamationApplication/shareClasses/0/series/0/maxNumberOfShares' + }]) + ]) +def test_validate_incorporation_share_classes(session, mocker, test_name, legal_type, + class_name_1, class_has_max_shares, class_max_shares, + has_par_value, par_value, currency, series_name_1, series_has_max_shares, + series_max_shares, + class_name_2, series_name_2, + expected_code, expected_msg): + """Assert that validator validates share class correctly.""" + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + + filing['filing']['amalgamationApplication']['nameRequest'] = {} + filing['filing']['amalgamationApplication']['nameRequest']['nrNumber'] = 'NR 1234567' + filing['filing']['amalgamationApplication']['nameRequest']['legalType'] = legal_type + + share_structure = filing['filing']['amalgamationApplication']['shareStructure'] + + share_structure['shareClasses'][0]['name'] = class_name_1 + share_structure['shareClasses'][0]['hasMaximumShares'] = class_has_max_shares + share_structure['shareClasses'][0]['maxNumberOfShares'] = class_max_shares + share_structure['shareClasses'][0]['hasParValue'] = has_par_value + share_structure['shareClasses'][0]['parValue'] = par_value + share_structure['shareClasses'][0]['currency'] = currency + share_structure['shareClasses'][0]['series'][0]['name'] = series_name_1 + share_structure['shareClasses'][0]['series'][0]['hasMaximumShares'] = series_has_max_shares + share_structure['shareClasses'][0]['series'][0]['maxNumberOfShares'] = series_max_shares + + if class_name_2: + # set second shareClass name + share_structure['shareClasses'][1]['name'] = class_name_2 + + if series_name_2: + # set 1st shareClass, 2nd series name + share_structure['shareClasses'][0]['series'][1]['name'] = series_name_2 + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_amalgamating_businesses', + return_value=[]) + + # perform test + err = validate(None, filing) + + # validate outcomes + if expected_code: + assert err.code == expected_code + assert lists_are_equal(err.msg, expected_msg) + else: + assert err is None + + +@pytest.mark.parametrize( + 'test_status, file_number, effect_of_order, expected_code, expected_msg', + [ + ('FAIL', '12345678901234567890', 'invalid', HTTPStatus.BAD_REQUEST, 'Invalid effectOfOrder.'), + ('SUCCESS', '12345678901234567890', 'planOfArrangement', None, None) + ] +) +def test_amalgamation_court_orders(mocker, app, session, + test_status, file_number, effect_of_order, expected_code, expected_msg): + """Assert valid court orders.""" + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + + court_order = {'effectOfOrder': effect_of_order} + if file_number: + court_order['fileNumber'] = file_number + filing['filing']['amalgamationApplication']['courtOrder'] = court_order + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_amalgamating_businesses', + return_value=[]) + err = validate(None, filing) + + # validate outcomes + if test_status == 'FAIL': + assert expected_code == err.code + assert expected_msg == err.msg[0]['error'] + else: + assert not err + + +@pytest.mark.parametrize( + 'test_status, expected_code, expected_msg', + [ + ('FAIL', HTTPStatus.BAD_REQUEST, 'Cannot amalgamate with BC1234567 which is in historical state.'), + ('SUCCESS', None, None) + ] +) +def test_is_business_historical(mocker, app, session, jwt, test_status, expected_code, expected_msg): + """Assert valid amalgamating businesses is historical.""" + account_id = '123456' + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + + def mock_find_by_identifier(identifier): + return LegalEntity(identifier=identifier, + entity_type=LegalEntity.EntityTypes.BCOMP.value, + state=LegalEntity.State.ACTIVE if test_status == 'SUCCESS' else LegalEntity.State.HISTORICAL) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._has_future_effective_filing', + return_value=False) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', side_effect=mock_find_by_identifier) + mocker.patch('legal_api.services.bootstrap.AccountService.get_account_by_affiliated_identifier', + return_value={'orgs': [{'id': account_id}]} if test_status == 'SUCCESS' else {}) + + mocker.patch('legal_api.utils.auth.jwt.validate_roles', return_value=True) # Staff + + err = validate(None, filing, account_id) + + # validate outcomes + if test_status == 'SUCCESS': + assert not err + else: + assert expected_code == err.code + assert expected_msg == err.msg[0]['error'] + + +@pytest.mark.parametrize( + 'test_status, expected_code, expected_msg', + [ + ('FAIL', HTTPStatus.BAD_REQUEST, 'BC1234567 has a future effective filing.'), + ('SUCCESS', None, None) + ] +) +def test_has_future_effective_filing(mocker, app, session, jwt, test_status, expected_code, expected_msg): + """Assert valid amalgamating businesses has future effective filing.""" + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', + return_value=LegalEntity(identifier='BC1234567', + entity_type=LegalEntity.EntityTypes.BCOMP.value)) + mocker.patch('legal_api.models.filing.Filing.get_filings_by_status', + return_value=[Filing()] if test_status == 'FAIL' else []) + + mocker.patch('legal_api.utils.auth.jwt.validate_roles', return_value=True) # Staff + + err = validate(None, filing) + + # validate outcomes + if test_status == 'SUCCESS': + assert not err + else: + assert expected_code == err.code + assert expected_msg == err.msg[0]['error'] + + +@pytest.mark.parametrize( + 'test_status, expected_code, expected_msg', + [ + ('FAIL', HTTPStatus.BAD_REQUEST, ['BC1234567', 'BC7654321']), + ('SUCCESS', None, None) + ] +) +def test_is_business_affliated(mocker, app, session, jwt, test_status, expected_code, expected_msg): + """Assert valid amalgamating businesses is affliated.""" + account_id = '123456' + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + filing['filing']['amalgamationApplication']['amalgamatingBusinesses'] = [ + { + 'role': 'amalgamating', + 'identifier': 'BC1234567' + }, + { + 'role': 'amalgamating', + 'identifier': 'BC7654321' + } + ] + + def mock_find_by_identifier(identifier): + return LegalEntity(identifier=identifier, + entity_type=LegalEntity.EntityTypes.BCOMP.value) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._has_future_effective_filing', + return_value=False) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', side_effect=mock_find_by_identifier) + mocker.patch('legal_api.services.bootstrap.AccountService.get_account_by_affiliated_identifier', + return_value={'orgs': [{'id': account_id}]} if test_status == 'SUCCESS' else {}) + + mocker.patch('legal_api.utils.auth.jwt.validate_roles', return_value=False) # Client + + err = validate(None, filing, account_id) + + # validate outcomes + if test_status == 'SUCCESS': + assert not err + else: + assert expected_code == err.code + assert f'{expected_msg[0]} is not affiliated with the currently selected BC Registries account.' == err.msg[0]['error'] + assert f'{expected_msg[1]} is not affiliated with the currently selected BC Registries account.' == err.msg[1]['error'] + + +@pytest.mark.parametrize( + 'test_status, expected_code, expected_msg', + [ + ('FAIL', HTTPStatus.BAD_REQUEST, ['BC1234567', 'BC7654321']), + ('SUCCESS', None, None) + ] +) +def test_is_business_in_good_standing(mocker, app, session, jwt, test_status, expected_code, expected_msg): + """Assert valid amalgamating businesses is in good standing.""" + account_id = '123456' + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + filing['filing']['amalgamationApplication']['amalgamatingBusinesses'] = [ + { + 'role': 'amalgamating', + 'identifier': 'BC1234567' + }, + { + 'role': 'amalgamating', + 'identifier': 'BC7654321' + } + ] + + def mock_find_by_identifier(identifier): + utc_now = datetime.datetime.now(datetime.timezone.utc) + return LegalEntity(identifier=identifier, + entity_type=LegalEntity.EntityTypes.BCOMP.value, + state=LegalEntity.State.ACTIVE, + founding_date=utc_now, + restoration_expiry_date=utc_now if test_status == 'FAIL' else None) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._has_future_effective_filing', + return_value=False) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._is_business_affliated', + return_value=True) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', side_effect=mock_find_by_identifier) + + mocker.patch('legal_api.utils.auth.jwt.validate_roles', return_value=False) # Client + + err = validate(None, filing, account_id) + + # validate outcomes + if test_status == 'SUCCESS': + assert not err + else: + assert expected_code == err.code + assert f'{expected_msg[0]} is not in good standing.' == err.msg[0]['error'] + assert f'{expected_msg[1]} is not in good standing.' == err.msg[1]['error'] + + +@pytest.mark.parametrize( + 'test_status, expected_code, expected_msg', + [ + ('FAIL', HTTPStatus.BAD_REQUEST, 'A business with identifier:BC7654321 not found.'), + ('SUCCESS', None, None) + ] +) +def test_is_business_not_found(mocker, app, session, jwt, test_status, expected_code, expected_msg): + """Assert valid amalgamating businesses not found.""" + account_id = '123456' + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + filing['filing']['amalgamationApplication']['amalgamatingBusinesses'] = [ + { + 'role': 'amalgamating', + 'identifier': 'BC1234567' + }, + { + 'role': 'amalgamating', + 'identifier': 'BC7654321' + } + ] + + def mock_find_by_identifier(identifier): + if test_status == 'FAIL' and identifier == 'BC7654321': + return None + + return LegalEntity(identifier=identifier, + entity_type=LegalEntity.EntityTypes.BCOMP.value) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._has_future_effective_filing', + return_value=False) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._is_business_affliated', + return_value=True) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', side_effect=mock_find_by_identifier) + + mocker.patch('legal_api.utils.auth.jwt.validate_roles', return_value=False) # Client + + err = validate(None, filing, account_id) + + # validate outcomes + if test_status == 'SUCCESS': + assert not err + else: + assert expected_code == err.code + assert expected_msg == err.msg[0]['error'] + + +@pytest.mark.parametrize( + 'test_status, role, expected_code, expected_msg', + [ + ('FAIL', BASIC_USER, HTTPStatus.BAD_REQUEST, + 'Foreign Co. foreign corporation cannot be amalgamated except by Registries staff.'), + ('SUCCESS', STAFF_ROLE, None, None) + ] +) +def test_amalgamating_foreign_business(mocker, app, session, jwt, test_status, role, expected_code, expected_msg): + """Assert valid amalgamating foreign business.""" + account_id = '123456' + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + + def mock_find_by_identifier(identifier): + return LegalEntity(identifier=identifier, + entity_type=LegalEntity.EntityTypes.BCOMP.value) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._has_future_effective_filing', + return_value=False) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._is_business_affliated', + return_value=True) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', side_effect=mock_find_by_identifier) + + def mock_validate_roles(required_roles): + if role in required_roles: + return True + return False + mocker.patch('legal_api.utils.auth.jwt.validate_roles', side_effect=mock_validate_roles) + + err = validate(None, filing, account_id) + + # validate outcomes + if test_status == 'SUCCESS': + assert not err + else: + assert expected_code == err.code + assert expected_msg == err.msg[0]['error'] + + +@pytest.mark.parametrize( + 'test_status, role, expected_code, expected_msg', + [ + ('FAIL', STAFF_ROLE, HTTPStatus.BAD_REQUEST, + 'Foreign Co. foreign corporation must not amalgamate with a BC company to form a BC Unlimited Liability Company.'), + ('SUCCESS', STAFF_ROLE, None, None) + ] +) +def test_amalgamating_foreign_business_with_bc_company_to_ulc(mocker, app, session, jwt, + test_status, role, expected_code, expected_msg): + """Assert valid amalgamating foreign business with bc company to form ulc.""" + account_id = '123456' + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + if test_status == 'FAIL': + filing['filing']['amalgamationApplication']['nameRequest']['legalType'] = 'ULC' + + def mock_find_by_identifier(identifier): + return LegalEntity(identifier=identifier, + entity_type=LegalEntity.EntityTypes.BCOMP.value) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._has_future_effective_filing', + return_value=False) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._is_business_affliated', + return_value=True) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', side_effect=mock_find_by_identifier) + + def mock_validate_roles(required_roles): + if role in required_roles: + return True + return False + mocker.patch('legal_api.utils.auth.jwt.validate_roles', side_effect=mock_validate_roles) + + err = validate(None, filing, account_id) + + # validate outcomes + if test_status == 'SUCCESS': + assert not err + else: + assert expected_code == err.code + assert expected_msg == err.msg[0]['error'] + + +@pytest.mark.parametrize( + 'test_status, role, expected_code, expected_msg', + [ + ('FAIL', STAFF_ROLE, HTTPStatus.BAD_REQUEST, + 'A BC Unlimited Liability Company cannot amalgamate with a foreign company Foreign Co..'), + ('SUCCESS', STAFF_ROLE, None, None) + ] +) +def test_amalgamating_foreign_business_with_ulc_company(mocker, app, session, jwt, + test_status, role, expected_code, expected_msg): + """Assert valid amalgamating foreign business with ulc company.""" + account_id = '123456' + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + + def mock_find_by_identifier(identifier): + return LegalEntity(identifier=identifier, + entity_type=LegalEntity.EntityTypes.BC_ULC_COMPANY.value + if test_status == 'FAIL' else LegalEntity.EntityTypes.BCOMP.value) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._has_future_effective_filing', + return_value=False) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._is_business_affliated', + return_value=True) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', side_effect=mock_find_by_identifier) + + def mock_validate_roles(required_roles): + if role in required_roles: + return True + return False + mocker.patch('legal_api.utils.auth.jwt.validate_roles', side_effect=mock_validate_roles) + + err = validate(None, filing, account_id) + + # validate outcomes + if test_status == 'SUCCESS': + assert not err + else: + assert expected_code == err.code + assert expected_msg == err.msg[0]['error'] + + +@pytest.mark.parametrize( + 'test_status, expected_code, expected_msg', + [ + ('FAIL', HTTPStatus.BAD_REQUEST, + 'A BC Community Contribution Company must amalgamate to form a new BC Community Contribution Company.'), + ('SUCCESS', None, None) + ] +) +def test_amalgamating_cc_to_cc(mocker, app, session, jwt, + test_status, expected_code, expected_msg): + """Assert valid amalgamating cc to cc.""" + account_id = '123456' + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + filing['filing']['amalgamationApplication']['nameRequest']['legalType'] = 'CC' + + def mock_find_by_identifier(identifier): + return LegalEntity(identifier=identifier, + entity_type=LegalEntity.EntityTypes.BCOMP.value + if test_status == 'FAIL' else LegalEntity.EntityTypes.BC_CCC.value) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._has_future_effective_filing', + return_value=False) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._is_business_affliated', + return_value=True) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', side_effect=mock_find_by_identifier) + + mocker.patch('legal_api.utils.auth.jwt.validate_roles', return_value=True) # Staff + + err = validate(None, filing, account_id) + + # validate outcomes + if test_status == 'SUCCESS': + assert not err + else: + assert expected_code == err.code + assert expected_msg == err.msg[0]['error'] + + +@pytest.mark.parametrize( + 'test_status, legal_type', + [ + ('FAIL', LegalEntity.EntityTypes.BC_CCC.value), + ('SUCCESS_CC', LegalEntity.EntityTypes.BC_CCC.value), + ('FAIL', LegalEntity.EntityTypes.BC_ULC_COMPANY.value), + ('SUCCESS_ULC', LegalEntity.EntityTypes.BC_ULC_COMPANY.value) + ] +) +def test_amalgamating_expro_to_cc_or_ulc(mocker, app, session, jwt, test_status, legal_type): + """Assert valid amalgamating expro with bc company to cc or ulc.""" + account_id = '123456' + filing = {'filing': {}} + filing['filing']['header'] = {'name': 'amalgamationApplication', 'date': '2019-04-08', + 'certifiedBy': 'full name', 'email': 'no_one@never.get', 'filingId': 1} + filing['filing']['amalgamationApplication'] = copy.deepcopy(AMALGAMATION_APPLICATION) + filing['filing']['amalgamationApplication']['nameRequest']['legalType'] = legal_type + filing['filing']['amalgamationApplication']['amalgamatingBusinesses'] = [ + { + 'role': 'amalgamating', + 'identifier': 'BC1234567' + }, + { + 'role': 'amalgamating', + 'legalName': 'Foreign Co.', + 'foreignJurisdiction': { + 'country': 'CA', + 'region': 'BC' + }, + 'corpNumber': 'A1234567' if test_status == 'FAIL' else '7654321' + } + ] + + def mock_find_by_identifier(identifier): + return LegalEntity(identifier=identifier, + entity_type=LegalEntity.EntityTypes.BC_CCC.value) + + mocker.patch('legal_api.services.filings.validations.amalgamation_application.validate_name_request', + return_value=[]) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._has_future_effective_filing', + return_value=False) + mocker.patch('legal_api.services.filings.validations.amalgamation_application._is_business_affliated', + return_value=True) + mocker.patch('legal_api.models.legal_entity.LegalEntity.find_by_identifier', side_effect=mock_find_by_identifier) + + mocker.patch('legal_api.utils.auth.jwt.validate_roles', return_value=True) # Staff + + err = validate(None, filing, account_id) + + # validate outcomes + expected_msg = 'An extra-Pro cannot amalgamate with anything to become a BC Unlimited Liability Company or a BC Community Contribution Company.' + if test_status == 'SUCCESS_CC': + assert not err + elif test_status == 'SUCCESS_ULC': + assert not next((x for x in err.msg if x['error'] == expected_msg), None) + else: + assert HTTPStatus.BAD_REQUEST == err.code + assert next((x for x in err.msg if x['error'] == expected_msg), None) diff --git a/legal-api/tests/unit/services/filings/validations/test_correction_firms.py b/legal-api/tests/unit/services/filings/validations/test_correction_firms.py index 1de4e67314..ee1c9cbeea 100644 --- a/legal-api/tests/unit/services/filings/validations/test_correction_firms.py +++ b/legal-api/tests/unit/services/filings/validations/test_correction_firms.py @@ -76,14 +76,9 @@ ("gp_correction", GP_CORRECTION_REGISTRATION_APPLICATION), ], ) -def test_valid_firms_correction(monkeypatch, app, session, jwt, test_name, filing): +def test_valid_firms_correction(mocker, app, session, jwt, test_name, filing): """Test that a valid Firms correction passes validation.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] - + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client # setup identifier = "FM1234567" founding_date = datetime(2022, 1, 1) @@ -97,17 +92,15 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me nr_res = copy.deepcopy(nr_response) nr_res["legalType"] = f["filing"]["correction"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=MockResponse(nr_res)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(legal_entity, f) + with patch.object(NameXService, "query_nr_number", return_value=MockResponse(nr_res)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(legal_entity, f) - if err: - print(err.msg) + if err: + print(err.msg) - # check that validation passed - assert None is err + # check that validation passed + assert None is err @pytest.mark.parametrize( @@ -121,14 +114,9 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ("gp_invalid_party", GP_CORRECTION_REGISTRATION_APPLICATION, "2 Partners and a Completing Party is required."), ], ) -def test_firms_correction_invalid_parties(monkeypatch, app, session, jwt, test_name, filing, expected_msg): +def test_firms_correction_invalid_parties(mocker, app, session, jwt, test_name, filing, expected_msg): """Test that a invalid Firms correction fails validation.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] - + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client # setup identifier = "FM1234567" legal_entity = factory_legal_entity(identifier) @@ -142,18 +130,16 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me del f["filing"]["correction"]["parties"][0]["roles"][0] nr_res = copy.deepcopy(nr_response) nr_res["legalType"] = f["filing"]["correction"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=MockResponse(nr_res)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(legal_entity, f) + with patch.object(NameXService, "query_nr_number", return_value=MockResponse(nr_res)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(legal_entity, f) - if err: - print(err.msg) + if err: + print(err.msg) - # check that validation passed - assert err - assert err.msg[0]["error"] == expected_msg + # check that validation passed + assert err + assert err.msg[0]["error"] == expected_msg @pytest.mark.parametrize( @@ -308,27 +294,10 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ), ], ) -def test_firms_correction_naics( - monkeypatch, - app, - session, - jwt, - test_name, - filing, - existing_naics_code, - existing_naics_desc, - correction_naics_code, - correction_naics_desc, - naics_response, - expected_msg, -): +def test_firms_correction_naics(mocker, app, session, jwt, test_name, filing, existing_naics_code, existing_naics_desc, + correction_naics_code, correction_naics_desc, naics_response, expected_msg): """Test that NAICS code and description are correctly validated.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] - + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client # setup identifier = "FM1234567" founding_date = datetime(2022, 1, 1) @@ -355,205 +324,56 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me nr_res = copy.deepcopy(nr_response) nr_res["legalType"] = f["filing"]["correction"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=MockResponse(nr_res)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(legal_entity, f) + with patch.object(NameXService, "query_nr_number", return_value=MockResponse(nr_res)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(legal_entity, f) - if err: - print(err.msg) + if err: + print(err.msg) - # check for expected validation resultsn - if expected_msg: - assert err - assert err.msg[0]["error"] == expected_msg - else: - assert None is err + # check for expected validation resultsn + if expected_msg: + assert err + assert err.msg[0]["error"] == expected_msg + else: + assert None is err -@pytest.mark.parametrize( - "test_name, filing, username, roles, founding_date_str, delta_date, is_valid", - [ - ( - "sp_no_correction_by_staff", - SP_CORRECTION_REGISTRATION_APPLICATION, - "staff", - [STAFF_ROLE], - "2022-01-01", - None, - True, - ), - ( - "gp_no_correction_by_staff", - GP_CORRECTION_REGISTRATION_APPLICATION, - "staff", - [STAFF_ROLE], - "2022-01-01", - None, - True, - ), - ( - "sp_correction_greater_by_staff", - SP_CORRECTION_REGISTRATION_APPLICATION, - "staff", - [STAFF_ROLE], - "2022-01-01", - timedelta(days=90), - True, - ), - ( - "gp_correction_greater_by_staff", - GP_CORRECTION_REGISTRATION_APPLICATION, - "staff", - [STAFF_ROLE], - "2022-01-01", - timedelta(days=90), - True, - ), - ( - "sp_correction_invalid_greater_by_staff", - SP_CORRECTION_REGISTRATION_APPLICATION, - "staff", - [STAFF_ROLE], - "2022-01-01", - timedelta(days=91), - False, - ), - ( - "gp_correction_invalid_greater_by_staff", - GP_CORRECTION_REGISTRATION_APPLICATION, - "staff", - [STAFF_ROLE], - "2022-01-01", - timedelta(days=91), - False, - ), - ( - "sp_correction_lesser_by_staff", - SP_CORRECTION_REGISTRATION_APPLICATION, - "staff", - [STAFF_ROLE], - "2022-01-01", - relativedelta(years=-20), - True, - ), - ( - "gp_correction_lesser_by_staff", - GP_CORRECTION_REGISTRATION_APPLICATION, - "staff", - [STAFF_ROLE], - "2022-01-01", - relativedelta(years=-20), - True, - ), - ( - "sp_no_correction_by_general_user", - SP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - None, - True, - ), - ( - "gp_no_correction_by_general_user", - GP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - None, - True, - ), - ( - "sp_correction_greater_by_general_user", - SP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - timedelta(days=90), - True, - ), - ( - "gp_correction_greater_by_general_user", - GP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - timedelta(days=90), - True, - ), - ( - "sp_correction_invalid_greater_by_general_user", - SP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - timedelta(days=91), - False, - ), - ( - "gp_correction_invalid_greater_by_general_user", - GP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - timedelta(days=91), - False, - ), - ( - "sp_correction_lesser_by_general_user", - SP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - relativedelta(years=-10), - True, - ), - ( - "gp_correction_lesser_by_general_user", - GP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - relativedelta(years=-10), - True, - ), - ( - "sp_correction_invalid_lesser_by_general_user", - SP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - relativedelta(years=-10, days=-1), - False, - ), - ( - "gp_correction_invalid_lesser_by_general_user", - GP_CORRECTION_REGISTRATION_APPLICATION, - "general user", - [BASIC_USER], - "2022-01-01", - relativedelta(years=-10, days=-1), - False, - ), - ], -) -def test_firms_correction_start_date( - monkeypatch, app, session, jwt, test_name, filing, username, roles, founding_date_str, delta_date, is_valid -): +@pytest.mark.parametrize("test_name, filing, username, roles, founding_date_str, delta_date, is_valid", + [ + ("sp_no_correction_by_staff", SP_CORRECTION_REGISTRATION_APPLICATION, "staff", STAFF_ROLE, "2022-01-01", None, True), + ("gp_no_correction_by_staff", GP_CORRECTION_REGISTRATION_APPLICATION, "staff", STAFF_ROLE, "2022-01-01", None, True), + ("sp_correction_greater_by_staff", SP_CORRECTION_REGISTRATION_APPLICATION, "staff", STAFF_ROLE, "2022-01-01", timedelta(days=90), True), + ("gp_correction_greater_by_staff", GP_CORRECTION_REGISTRATION_APPLICATION, "staff", STAFF_ROLE, "2022-01-01", timedelta(days=90), True), + ("sp_correction_invalid_greater_by_staff", SP_CORRECTION_REGISTRATION_APPLICATION, "staff", STAFF_ROLE, "2022-01-01", timedelta(days=91), False), + ("gp_correction_invalid_greater_by_staff", GP_CORRECTION_REGISTRATION_APPLICATION, "staff", STAFF_ROLE, "2022-01-01", timedelta(days=91), False), + ("sp_correction_lesser_by_staff", SP_CORRECTION_REGISTRATION_APPLICATION, "staff", STAFF_ROLE, "2022-01-01", relativedelta(years=-20), True), + ("gp_correction_lesser_by_staff", GP_CORRECTION_REGISTRATION_APPLICATION, "staff", STAFF_ROLE, "2022-01-01", relativedelta(years=-20), True), + + ("sp_no_correction_by_general_user", SP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", None, True), + ("gp_no_correction_by_general_user", GP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", None, True), + ("sp_correction_greater_by_general_user", SP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", timedelta(days=90), True), + ("gp_correction_greater_by_general_user", GP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", timedelta(days=90), True), + ("sp_correction_invalid_greater_by_general_user", SP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", timedelta(days=91), False), + ("gp_correction_invalid_greater_by_general_user", GP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", timedelta(days=91), False), + ("sp_correction_lesser_by_general_user", SP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", relativedelta(years=-10), True), + ("gp_correction_lesser_by_general_user", GP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", relativedelta(years=-10), True), + ("sp_correction_invalid_lesser_by_general_user", SP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", relativedelta(years=-10, days=-1), False), + ("gp_correction_invalid_lesser_by_general_user", GP_CORRECTION_REGISTRATION_APPLICATION, "general user", [BASIC_USER], "2022-01-01", relativedelta(years=-10, days=-1), False), + ]) +def test_firms_correction_start_date(mocker, app, session, jwt, test_name, filing, username, roles, founding_date_str, delta_date, is_valid): """Test that start date of firms is correctly validated.""" - token = helper_create_jwt(jwt, roles=roles, username=username) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] + def mock_validate_roles(required_roles): + if roles in required_roles: + return True + return False + mocker.patch("legal_api.utils.auth.jwt.validate_roles", side_effect=mock_validate_roles) # Client identifier = "FM1234567" founding_date = datetime.strptime(founding_date_str, "%Y-%m-%d") - business = factory_legal_entity(identifier=identifier, founding_date=founding_date) + legal_entity = factory_legal_entity(identifier=identifier, founding_date=founding_date) - corrected_filing = factory_completed_filing(business, CHANGE_OF_REGISTRATION_APPLICATION) + corrected_filing = factory_completed_filing(legal_entity, CHANGE_OF_REGISTRATION_APPLICATION) start_date = founding_date if delta_date: @@ -567,13 +387,11 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me nr_res = copy.deepcopy(nr_response) nr_res["legalType"] = f["filing"]["correction"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=MockResponse(nr_res)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(business, f) - - if is_valid: - assert not err - else: - assert err + with patch.object(NameXService, "query_nr_number", return_value=MockResponse(nr_res)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(legal_entity, f) + + if is_valid: + assert not err + else: + assert err diff --git a/legal-api/tests/unit/services/filings/validations/test_correction_special_resolution.py b/legal-api/tests/unit/services/filings/validations/test_correction_special_resolution.py index 528f6ab360..9c5d7b2965 100644 --- a/legal-api/tests/unit/services/filings/validations/test_correction_special_resolution.py +++ b/legal-api/tests/unit/services/filings/validations/test_correction_special_resolution.py @@ -14,7 +14,9 @@ """Test Correction SPECIAL_RESOLUTION validations.""" import copy +from http import HTTPStatus +import pytest from registry_schemas.example_data import ( CORRECTION_CP_SPECIAL_RESOLUTION, CP_SPECIAL_RESOLUTION_TEMPLATE, @@ -23,6 +25,7 @@ from legal_api.services.filings import validate from tests.unit.models import factory_completed_filing, factory_legal_entity +from tests.unit.services.filings.validations import lists_are_equal CP_SPECIAL_RESOLUTION_APPLICATION = copy.deepcopy(CP_SPECIAL_RESOLUTION_TEMPLATE) @@ -48,3 +51,69 @@ def test_valid_special_resolution_correction(session): # check that validation passed assert None is err + + +@pytest.mark.parametrize('test_name, legal_type, correction_type, err_msg', [ + ('valid_parties', 'CP', 'CLIENT', None), + ('valid_parties', 'CP', 'STAFF', None), + ('no_parties', 'CP', 'CLIENT', + [{'error': 'Parties list cannot be empty or null', 'path': '/filing/correction/parties/roles'}]), + ('no_parties', 'CP', 'STAFF', + [{'error': 'Parties list cannot be empty or null', 'path': '/filing/correction/parties/roles'}]), + ('empty_parties', 'CP', 'CLIENT', + [{'error': 'Parties list cannot be empty or null', 'path': '/filing/correction/parties/roles'}]), + ('empty_parties', 'CP', 'STAFF', + [{'error': 'Parties list cannot be empty or null', 'path': '/filing/correction/parties/roles'}]), + ('no_roles', 'CP', 'CLIENT', + [{'error': 'Must have a minimum of one completing party', 'path': '/filing/correction/parties/roles'}, + {'error': 'Must have a minimum of three Directors', 'path': '/filing/correction/parties/roles'}]), + ('no_roles', 'CP', 'STAFF', + [{'error': 'Must have a minimum of three Directors', 'path': '/filing/correction/parties/roles'}]), + ('only_completing', 'CP', 'CLIENT', + [{'error': 'Must have a minimum of three Directors', 'path': '/filing/correction/parties/roles'}]), + ('only_completing', 'CP', 'STAFF', + [{'error': 'Should not provide completing party when correction type is STAFF', 'path': '/filing/correction/parties/roles'}, + {'error': 'Must have a minimum of three Directors', 'path': '/filing/correction/parties/roles'}]), +]) +def test_parties_special_resolution_correction(session, test_name, legal_type, correction_type, err_msg): + """Test parties for SPECIAL_RESOLUTION correction.""" + # setup + identifier = 'BC1234567' + business = factory_legal_entity(identifier) + corrected_filing = factory_completed_filing(business, CP_SPECIAL_RESOLUTION_APPLICATION) + + correction_data = copy.deepcopy(FILING_HEADER) + correction_data['filing']['correction'] = copy.deepcopy(CORRECTION_CP_SPECIAL_RESOLUTION) + correction_data['filing']['header']['name'] = 'correction' + + f = copy.deepcopy(correction_data) + f['filing']['header']['identifier'] = identifier + f['filing']['correction']['correctedFilingId'] = corrected_filing.id + f['filing']['correction']['type'] = correction_type + + if test_name == 'no_roles': + f['filing']['correction']['parties'][0]['roles'] = [] + f['filing']['correction']['parties'][1]['roles'] = [] + f['filing']['correction']['parties'][2]['roles'] = [] + elif test_name == "no_parties": + del f['filing']['correction']['parties'] + elif test_name == "empty_parties": + f['filing']['correction']['parties'] = [] + elif test_name == "only_completing": + del f['filing']['correction']['parties'][2] + del f['filing']['correction']['parties'][1] + del f['filing']['correction']['parties'][0]['roles'][1] + elif test_name == 'valid_parties': + if correction_type == 'STAFF': + del f['filing']['correction']['parties'][0]['roles'][0] # completing party + + err = validate(business, f) + if err: + print(err.msg) + + if err_msg: + assert err + assert HTTPStatus.BAD_REQUEST == err.code + assert lists_are_equal(err.msg, err_msg) + else: + assert None is err diff --git a/legal-api/tests/unit/services/filings/validations/test_registration.py b/legal-api/tests/unit/services/filings/validations/test_registration.py index 3dc75854c8..f8fec56387 100644 --- a/legal-api/tests/unit/services/filings/validations/test_registration.py +++ b/legal-api/tests/unit/services/filings/validations/test_registration.py @@ -109,101 +109,67 @@ def _mock_nr_response(entity_type): ) -def test_gp_registration(monkeypatch, app, session, jwt): +def test_gp_registration(mocker, app, session, jwt): """Assert that the general partnership registration is valid.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response("GP")): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, GP_REGISTRATION) - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response("GP")): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, GP_REGISTRATION) - - assert not err + assert not err -def test_sp_registration(monkeypatch, app, session, jwt): +def test_sp_registration(mocker, app, session, jwt): """Assert that the general partnership registration is valid.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] - - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response("SP")): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, SP_REGISTRATION) + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response("SP")): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, SP_REGISTRATION) - assert not err + assert not err -def test_dba_registration(monkeypatch, app, session, jwt): +def test_dba_registration(mocker, app, session, jwt): """Assert that the general partnership registration is valid.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response("SP")): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, DBA_REGISTRATION) - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response("SP")): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, DBA_REGISTRATION) - - assert not err + assert not err -def test_invalid_nr_registration(monkeypatch, app, session, jwt): +def test_invalid_nr_registration(mocker, app, session, jwt): """Assert that nr is invalid.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] - + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client filing = copy.deepcopy(SP_REGISTRATION) invalid_nr_response = { "state": "INPROGRESS", "expirationDate": "", "names": [{"name": "legal_name", "state": "INPROGRESS", "consumptionDate": ""}], } - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=MockResponse(invalid_nr_response)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, filing) + with patch.object(NameXService, "query_nr_number", return_value=MockResponse(invalid_nr_response)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, filing) - assert err - assert err.msg[0]["error"] == "Name Request is not approved." + assert err + assert err.msg[0]["error"] == "Name Request is not approved." -def test_business_type_required(monkeypatch, app, session, jwt): +def test_business_type_required(mocker, app, session, jwt): """Assert that business type is required.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] - + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client filing = copy.deepcopy(SP_REGISTRATION) del filing["filing"]["registration"]["businessType"] entity_type = filing["filing"]["registration"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, filing) + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, filing) - assert err - assert err.msg[0]["error"] == "Business Type is required." + assert err + assert err.msg[0]["error"] == "Business Type is required." @pytest.mark.parametrize( @@ -213,50 +179,36 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ("valid_taxId", "123456789", None), ], ) -def test_validate_tax_id(monkeypatch, app, session, jwt, test_name, tax_id, expected): +def test_validate_tax_id(mocker, app, session, jwt, test_name, tax_id, expected): """Assert that taxId is validated.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] - + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client filing = copy.deepcopy(SP_REGISTRATION) filing["filing"]["registration"]["business"]["taxId"] = tax_id entity_type = filing["filing"]["registration"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, filing) + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, filing) - if expected: - assert err - assert err.msg[0]["error"] == expected - else: - assert err is None + if expected: + assert err + assert err.msg[0]["error"] == expected + else: + assert err is None def test_naics_invalid(monkeypatch, app, session, jwt): """Assert that naics is invalid.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] filing = copy.deepcopy(SP_REGISTRATION) entity_type = filing["filing"]["registration"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): - with patch.object(NaicsService, "find_by_code", return_value={}): - err = validate(None, filing) + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): + with patch.object(NaicsService, "find_by_code", return_value={}): + err = validate(None, filing) - assert err - assert err.msg[0]["error"] == "Invalid naics code or description." + assert err + assert err.msg[0]["error"] == "Invalid naics code or description." @pytest.mark.parametrize( @@ -267,25 +219,18 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ("gp_invalid_party", copy.deepcopy(GP_REGISTRATION), "2 Partners and a Completing Party is required."), ], ) -def test_invalid_party(monkeypatch, app, session, jwt, test_name, filing, expected_msg): +def test_invalid_party(mocker, app, session, jwt, test_name, filing, expected_msg): """Assert that party is invalid.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] - + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client filing["filing"]["registration"]["parties"] = [] entity_type = filing["filing"]["registration"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, filing) + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, filing) - assert err - assert err.msg[0]["error"] == expected_msg + assert err + assert err.msg[0]["error"] == expected_msg @pytest.mark.parametrize( @@ -296,50 +241,44 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ("gp_invalid_business_address", copy.deepcopy(GP_REGISTRATION)), ], ) -def test_invalid_business_address(monkeypatch, app, session, jwt, test_name, filing): +def test_invalid_business_address(mocker, app, session, jwt, test_name, filing): """Assert that delivery business address is invalid.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) # Client filing["filing"]["registration"]["offices"]["businessOffice"]["deliveryAddress"]["addressRegion"] = "invalid" filing["filing"]["registration"]["offices"]["businessOffice"]["deliveryAddress"]["addressCountry"] = "invalid" entity_type = filing["filing"]["registration"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, filing) + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, filing) - assert err - assert err.msg[0]["error"] == "Address Region must be 'BC'." - assert err.msg[1]["error"] == "Address Country must be 'CA'." + assert err + assert err.msg[0]["error"] == "Address Region must be 'BC'." + assert err.msg[1]["error"] == "Address Country must be 'CA'." @pytest.mark.parametrize( "test_name, username, roles, delta_date, is_valid", [ - ("staff_today", "staff", [STAFF_ROLE], None, True), - ("staff_greater", "staff", [STAFF_ROLE], timedelta(days=90), True), - ("staff_invalid_greater", "staff", [STAFF_ROLE], timedelta(days=91), False), - ("staff_lesser", "staff", [STAFF_ROLE], relativedelta(years=-20), True), + ("staff_today", "staff", STAFF_ROLE, None, True), + ("staff_greater", "staff", STAFF_ROLE, timedelta(days=90), True), + ("staff_invalid_greater", "staff", STAFF_ROLE, timedelta(days=91), False), + ("staff_lesser", "staff", STAFF_ROLE, relativedelta(years=-20), True), ("general_user_today", "general", [BASIC_USER], None, True), ("general_user_greater", "general", [BASIC_USER], timedelta(days=90), True), ("general_user_invalid_greater", "general", [BASIC_USER], timedelta(days=91), False), ("general_user_lesser", "general", [BASIC_USER], relativedelta(years=-10), True), - ("general_user_invalid_lesser", "general", [BASIC_USER], relativedelta(years=-10, days=-1), False), - ], + ("general_user_invalid_lesser", "general", [BASIC_USER], relativedelta(years=-10, days=-1), False) + ] ) -def test_validate_start_date(monkeypatch, app, session, jwt, test_name, username, roles, delta_date, is_valid): +def test_validate_start_date(mocker, app, session, jwt, test_name, username, roles, delta_date, is_valid): """Assert that start date is validated.""" - token = helper_create_jwt(jwt, roles=roles, username=username) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] + def mock_validate_roles(required_roles): + if roles in required_roles: + return True + return False + mocker.patch("legal_api.utils.auth.jwt.validate_roles", side_effect=mock_validate_roles) # Client start_date = LegislationDatetime.now() if delta_date: @@ -350,16 +289,15 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me entity_type = filing["filing"]["registration"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, filing) + entity_type = filing["filing"]["registration"]["nameRequest"]["legalType"] + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, filing) - if is_valid: - assert not err - else: - assert err + if is_valid: + assert not err + else: + assert err @pytest.mark.parametrize( @@ -369,16 +307,9 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ("SUCCESS", "12345678901234567890", "planOfArrangement", None, None), ], ) -def test_registration_court_orders( - monkeypatch, app, session, jwt, test_status, file_number, effect_of_order, expected_code, expected_msg -): +def test_registration_court_orders(mocker, app, session, jwt, test_status, file_number, effect_of_order, expected_code, expected_msg): """Assert valid court orders.""" - token = helper_create_jwt(jwt) - headers = {"Authorization": "Bearer " + token} - - def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods - return headers[one] - + mocker.patch("legal_api.utils.auth.jwt.validate_roles", return_value=False) filing = copy.deepcopy(GP_REGISTRATION) court_order = {"effectOfOrder": effect_of_order} @@ -387,15 +318,13 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me filing["filing"]["registration"]["courtOrder"] = court_order entity_type = filing["filing"]["registration"]["nameRequest"]["legalType"] - with app.test_request_context(): - monkeypatch.setattr("flask.request.headers.get", mock_auth) - with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): - with patch.object(NaicsService, "find_by_code", return_value=naics_response): - err = validate(None, filing) - - # validate outcomes - if test_status == "FAIL": - assert expected_code == err.code - assert expected_msg == err.msg[0]["error"] - else: - assert not err + with patch.object(NameXService, "query_nr_number", return_value=_mock_nr_response(entity_type)): + with patch.object(NaicsService, "find_by_code", return_value=naics_response): + err = validate(None, filing) + + # validate outcomes + if test_status == "FAIL": + assert expected_code == err.code + assert expected_msg == err.msg[0]["error"] + else: + assert not err diff --git a/legal-api/tests/unit/services/test_authorization.py b/legal-api/tests/unit/services/test_authorization.py index 8a38927a46..83e4e34e5f 100644 --- a/legal-api/tests/unit/services/test_authorization.py +++ b/legal-api/tests/unit/services/test_authorization.py @@ -21,10 +21,14 @@ from datetime import datetime as _datetime from enum import Enum from http import HTTPStatus +import jwt as pyjwt import pytest +from unittest.mock import patch, PropertyMock, MagicMock from flask import jsonify from registry_schemas.example_data import ( + AGM_EXTENSION, + AGM_LOCATION_CHANGE, ALTERATION_FILING_TEMPLATE, ANNUAL_REPORT, CHANGE_OF_REGISTRATION_TEMPLATE, @@ -38,17 +42,20 @@ RESTORATION, ) -from legal_api.models import Filing +from legal_api.models import Address, EntityRole, Filing, User from legal_api.models.legal_entity import LegalEntity from legal_api.services.authz import ( BASIC_USER, COLIN_SVC_ROLE, + PUBLIC_USER, STAFF_ROLE, + are_digital_credentials_allowed, authorized, get_allowable_actions, get_allowed, get_allowed_filings, is_allowed, + is_self_registered_owner_operator, ) from legal_api.services.warnings.business.business_checks import WarningType from tests import integration_authorization, not_github_ci @@ -57,6 +64,8 @@ factory_filing, factory_incomplete_statuses, factory_legal_entity, + factory_party_role, + factory_user, ) from .utils import helper_create_jwt @@ -162,6 +171,8 @@ class FilingKey(str, Enum): REGISTRARS_NOTATION = "REGISTRARS_NOTATION" REGISTRARS_ORDER = "REGISTRARS_ORDER" SPECIAL_RESOLUTION = "SPECIAL_RESOLUTION" + AGM_EXTENSION = "AGM_EXTENSION" + AGM_LOCATION_CHANGE = "AGM_LOCATION_CHANGE" ALTERATION = "ALTERATION" CONSENT_CONTINUATION_OUT = "CONSENT_CONTINUATION_OUT" CONTINUATION_OUT = "CONTINUATION_OUT" @@ -175,6 +186,9 @@ class FilingKey(str, Enum): RESTRN_LTD_TO_FULL_CORPS = "RESTRN_LTD_TO_FULL_CORPS" RESTRN_LTD_TO_FULL_LLC = "RESTRN_LTD_TO_FULL_LLC" PUT_BACK_ON = "PUT_BACK_ON" + AMALGAMATION_REGULAR = "AMALGAMATION_REGULAR" + AMALGAMATION_VERTICAL = "AMALGAMATION_VERTICAL" + AMALGAMATION_HORIZONTAL = "AMALGAMATION_HORIZONTAL" EXPECTED_DATA = { @@ -186,139 +200,75 @@ class FilingKey(str, Enum): FilingKey.COD_CP: {"displayName": "Director Change", "feeCode": "OTCDR", "name": "changeOfDirectors"}, FilingKey.COD_CORPS: {"displayName": "Director Change", "feeCode": "BCCDR", "name": "changeOfDirectors"}, FilingKey.CORRCTN: {"displayName": "Register Correction Application", "feeCode": "CRCTN", "name": "correction"}, - FilingKey.CORRCTN_FIRMS: { - "displayName": "Register Correction Application", - "feeCode": "FMCORR", - "name": "correction", - }, + FilingKey.CORRCTN_FIRMS: {"displayName": "Register Correction Application", "feeCode": "FMCORR", + "name": "correction"}, FilingKey.COURT_ORDER: {"displayName": "Court Order", "feeCode": "NOFEE", "name": "courtOrder"}, - FilingKey.VOL_DISS: { - "displayName": "Voluntary Dissolution", - "feeCode": "DIS_VOL", - "name": "dissolution", - "type": "voluntary", - }, - FilingKey.ADM_DISS: { - "displayName": "Administrative Dissolution", - "feeCode": "DIS_ADM", - "name": "dissolution", - "type": "administrative", - }, - FilingKey.VOL_DISS_FIRMS: { - "displayName": "Statement of Dissolution", - "feeCode": "DIS_VOL", - "name": "dissolution", - "type": "voluntary", - }, - FilingKey.ADM_DISS_FIRMS: { - "displayName": "Statement of Dissolution", - "feeCode": "DIS_ADM", - "name": "dissolution", - "type": "administrative", - }, - FilingKey.REGISTRARS_NOTATION: { - "displayName": "Registrar's Notation", - "feeCode": "NOFEE", - "name": "registrarsNotation", - }, + FilingKey.VOL_DISS: {"displayName": "Voluntary Dissolution", "feeCode": "DIS_VOL", + "name": "dissolution", "type": "voluntary"}, + FilingKey.ADM_DISS: {"displayName": "Administrative Dissolution", "feeCode": "DIS_ADM", + "name": "dissolution", "type": "administrative"}, + FilingKey.VOL_DISS_FIRMS: {"displayName": "Statement of Dissolution", "feeCode": "DIS_VOL", + "name": "dissolution", "type": "voluntary"}, + FilingKey.ADM_DISS_FIRMS: {"displayName": "Statement of Dissolution", "feeCode": "DIS_ADM", + "name": "dissolution", "type": "administrative"}, + FilingKey.REGISTRARS_NOTATION: {"displayName": "Registrar's Notation", "feeCode": "NOFEE", + "name": "registrarsNotation"}, FilingKey.REGISTRARS_ORDER: {"displayName": "Registrar's Order", "feeCode": "NOFEE", "name": "registrarsOrder"}, - FilingKey.SPECIAL_RESOLUTION: { - "displayName": "Special Resolution", - "feeCode": "SPRLN", - "name": "specialResolution", - }, + FilingKey.SPECIAL_RESOLUTION: {"displayName": "Special Resolution", "feeCode": "SPRLN", "name": "specialResolution"}, + FilingKey.AGM_EXTENSION: {"displayName": "Request for AGM Extension", "feeCode": "AGMDT", "name": "agmExtension"}, + FilingKey.AGM_LOCATION_CHANGE: {"displayName": "AGM Location Change", "feeCode": "AGMLC", "name": "agmLocationChange"}, FilingKey.ALTERATION: {"displayName": "Alteration", "feeCode": "ALTER", "name": "alteration"}, - FilingKey.CONSENT_CONTINUATION_OUT: { - "displayName": "6-Month Consent to Continue Out", - "feeCode": "CONTO", - "name": "consentContinuationOut", - }, + FilingKey.CONSENT_CONTINUATION_OUT: {"displayName": "6-Month Consent to Continue Out", "feeCode": "CONTO", + "name": "consentContinuationOut"}, FilingKey.CONTINUATION_OUT: {"displayName": "Continuation Out", "feeCode": "COUTI", "name": "continuationOut"}, FilingKey.TRANSITION: {"displayName": "Transition Application", "feeCode": "TRANS", "name": "transition"}, - FilingKey.IA_CP: { - "displayName": "Incorporation Application", - "feeCode": "OTINC", - "name": "incorporationApplication", - }, - FilingKey.IA_BC: { - "displayName": "BC Limited Company Incorporation Application", - "feeCode": "BCINC", - "name": "incorporationApplication", - }, - FilingKey.IA_BEN: { - "displayName": "BC Benefit Company Incorporation Application", - "feeCode": "BCINC", - "name": "incorporationApplication", - }, - FilingKey.IA_CC: { - "displayName": "BC Community Contribution Company Incorporation Application", - "feeCode": "BCINC", - "name": "incorporationApplication", - }, - FilingKey.IA_ULC: { - "displayName": "BC Unlimited Liability Company Incorporation Application", - "feeCode": "BCINC", - "name": "incorporationApplication", - }, - FilingKey.REG_SP: { - "displayName": "BC Sole Proprietorship Registration", - "feeCode": "FRREG", - "name": "registration", - }, - FilingKey.REG_GP: { - "displayName": "BC General Partnership Registration", - "feeCode": "FRREG", - "name": "registration", - }, - FilingKey.CHANGE_OF_REGISTRATION: { - "displayName": "Change of Registration Application", - "feeCode": "FMCHANGE", - "name": "changeOfRegistration", - }, + FilingKey.IA_CP: {"displayName": "Incorporation Application", "feeCode": "OTINC", + "name": "incorporationApplication"}, + FilingKey.IA_BC: {"displayName": "BC Limited Company Incorporation Application", "feeCode": "BCINC", + "name": "incorporationApplication"}, + FilingKey.IA_BEN: {"displayName": "BC Benefit Company Incorporation Application", "feeCode": "BCINC", + "name": "incorporationApplication"}, + FilingKey.IA_CC: {"displayName": "BC Community Contribution Company Incorporation Application", "feeCode": "BCINC", + "name": "incorporationApplication"}, + FilingKey.IA_ULC: {"displayName": "BC Unlimited Liability Company Incorporation Application", "feeCode": "BCINC", + "name": "incorporationApplication"}, + FilingKey.REG_SP: {"displayName": "BC Sole Proprietorship Registration", "feeCode": "FRREG", + "name": "registration"}, + FilingKey.REG_GP: {"displayName": "BC General Partnership Registration", "feeCode": "FRREG", + "name": "registration"}, + FilingKey.CHANGE_OF_REGISTRATION: {"displayName": "Change of Registration Application", "feeCode": "FMCHANGE", + "name": "changeOfRegistration"}, FilingKey.CONV_FIRMS: {"displayName": "Record Conversion", "feeCode": "FMCONV", "name": "conversion"}, - FilingKey.RESTRN_FULL_CORPS: { - "displayName": "Full Restoration Application", - "feeCode": "RESTF", - "name": "restoration", - "type": "fullRestoration", - }, - FilingKey.RESTRN_LTD_CORPS: { - "displayName": "Limited Restoration Application", - "feeCode": "RESTL", - "name": "restoration", - "type": "limitedRestoration", - }, - FilingKey.RESTRN_LTD_EXT_CORPS: { - "displayName": "Limited Restoration Extension Application", - "feeCode": "RESXL", - "name": "restoration", - "type": "limitedRestorationExtension", - }, - FilingKey.RESTRN_LTD_TO_FULL_CORPS: { - "displayName": "Conversion to Full Restoration Application", - "feeCode": "RESXF", - "name": "restoration", - "type": "limitedRestorationToFull", - }, - FilingKey.RESTRN_LTD_EXT_LLC: { - "displayName": "Limited Restoration Extension Application", - "feeCode": None, - "name": "restoration", - "type": "limitedRestorationExtension", - }, - FilingKey.RESTRN_LTD_TO_FULL_LLC: { - "displayName": "Conversion to Full Restoration Application", - "feeCode": None, - "name": "restoration", - "type": "limitedRestorationToFull", - }, + FilingKey.RESTRN_FULL_CORPS: {"displayName": "Full Restoration Application", "feeCode": "RESTF", + "name": "restoration", "type": "fullRestoration"}, + FilingKey.RESTRN_LTD_CORPS: {"displayName": "Limited Restoration Application", "feeCode": "RESTL", + "name": "restoration", "type": "limitedRestoration"}, + FilingKey.RESTRN_LTD_EXT_CORPS: {"displayName": "Limited Restoration Extension Application", "feeCode": "RESXL", + "name": "restoration", "type": "limitedRestorationExtension"}, + FilingKey.RESTRN_LTD_TO_FULL_CORPS: {"displayName": "Conversion to Full Restoration Application", "feeCode": "RESXF", + "name": "restoration", "type": "limitedRestorationToFull"}, + FilingKey.RESTRN_LTD_EXT_LLC: {"displayName": "Limited Restoration Extension Application", "feeCode": None, + "name": "restoration", "type": "limitedRestorationExtension"}, + FilingKey.RESTRN_LTD_TO_FULL_LLC: {"displayName": "Conversion to Full Restoration Application", "feeCode": None, + "name": "restoration", "type": "limitedRestorationToFull"}, FilingKey.PUT_BACK_ON: {"displayName": "Correction - Put Back On", "feeCode": "NOFEE", "name": "putBackOn"}, + FilingKey.AMALGAMATION_REGULAR: {"name": "amalgamationApplication", "type": "regular", "displayName": "Regular Amalgamation", "feeCode": "AMALR"}, + FilingKey.AMALGAMATION_VERTICAL: {"name": "amalgamationApplication", "type": "vertical", "displayName": "Vertical Amalgamation", "feeCode": "AMALV"}, + FilingKey.AMALGAMATION_HORIZONTAL: {"name": "amalgamationApplication", "type": "horizontal", "displayName": "Horizontal Amalgamation", "feeCode": "AMALH"} } BLOCKER_FILING_STATUSES = factory_incomplete_statuses() -BLOCKER_FILING_STATUSES_AND_ADDITIONAL = factory_incomplete_statuses(["unknown_status_1", "unknown_status_2"]) +BLOCKER_FILING_STATUSES_AND_ADDITIONAL = factory_incomplete_statuses(["unknown_status_1", + "unknown_status_2"]) +BLOCKER_DISSOLUTION_STATUSES_FOR_AMALG = [Filing.Status.PENDING.value, Filing.Status.PAID.value] BLOCKER_FILING_TYPES = ["alteration", "correction"] +AGM_EXTENSION_FILING_TEMPLATE = copy.deepcopy(FILING_TEMPLATE) +AGM_EXTENSION_FILING_TEMPLATE["filing"]["agmExtension"] = AGM_EXTENSION + +AGM_LOCATION_CHANGE_FILING_TEMPLATE = copy.deepcopy(FILING_TEMPLATE) +AGM_LOCATION_CHANGE_FILING_TEMPLATE["filing"]["agmLocationChange"] = AGM_LOCATION_CHANGE + RESTORATION_FILING_TEMPLATE = copy.deepcopy(FILING_TEMPLATE) RESTORATION_FILING_TEMPLATE["filing"]["restoration"] = RESTORATION @@ -338,6 +288,8 @@ class FilingKey(str, Enum): CONSENT_CONTINUATION_OUT_TEMPLATE["filing"]["consentContinuationOut"] = CONSENT_CONTINUATION_OUT FILING_DATA = { + "agmExtension": AGM_EXTENSION_FILING_TEMPLATE, + "agmLocationChange": AGM_LOCATION_CHANGE_FILING_TEMPLATE, "alteration": ALTERATION_FILING_TEMPLATE, "correction": CORRECTION_AR, "changeOfRegistration": CHANGE_OF_REGISTRATION_TEMPLATE, @@ -575,6 +527,8 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me [STAFF_ROLE], [ "adminFreeze", + "agmExtension", + "agmLocationChange", "alteration", "annualReport", "changeOfAddress", @@ -632,6 +586,8 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me "general", [BASIC_USER], [ + "agmExtension", + "agmLocationChange", "alteration", "annualReport", "changeOfAddress", @@ -705,6 +661,15 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me "test_name,state,filing_type,sub_filing_type,legal_types,username,roles,expected", [ # active business + ("staff_active_allowed", LegalEntity.State.ACTIVE, "agmExtension", None, + ["BC", "BEN", "ULC", "CC"], "staff", [STAFF_ROLE], True), + ("staff_active", LegalEntity.State.ACTIVE, "agmExtension", None, + ["CP", "LLC"], "staff", [STAFF_ROLE], False), + + ("staff_active_allowed", LegalEntity.State.ACTIVE, "agmLocationChange", None, + ["BC", "BEN", "ULC", "CC"], "staff", [STAFF_ROLE], True), + ("staff_active", LegalEntity.State.ACTIVE, "agmLocationChange", None, + ["CP", "LLC"], "staff", [STAFF_ROLE], False), ( "staff_active_allowed", LegalEntity.State.ACTIVE, @@ -952,6 +917,15 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me [STAFF_ROLE], True, ), + ("user_active_allowed", LegalEntity.State.ACTIVE, "agmExtension", None, + ["BC", "BEN", "ULC", "CC"], "general", [BASIC_USER], True), + ("user_active", LegalEntity.State.ACTIVE, "agmExtension", None, + ["CP", "LLC"], "general", [BASIC_USER], False), + + ("user_active_allowed", LegalEntity.State.ACTIVE, "agmLocationChange", None, + ["BC", "BEN", "ULC", "CC"], "general", [BASIC_USER], True), + ("user_active", LegalEntity.State.ACTIVE, "agmLocationChange", None, + ["CP", "LLC"], "general", [BASIC_USER], False), ( "user_active_allowed", LegalEntity.State.ACTIVE, @@ -1553,117 +1527,287 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me "test_name,business_exists,state,legal_types,username,roles,expected", [ # active business - staff user + ("staff_active_cp", True, LegalEntity.State.ACTIVE, ["CP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.SPECIAL_RESOLUTION])), + ("staff_active_corps", True, LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + ("staff_active_llc", True, LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], []), + ("staff_active_firms", True, LegalEntity.State.ACTIVE, ["SP", "GP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.CHANGE_OF_REGISTRATION, + FilingKey.CONV_FIRMS, + FilingKey.CORRCTN_FIRMS, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS_FIRMS, + FilingKey.ADM_DISS_FIRMS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + + # active business - general user + ("general_user_cp", True, LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], + expected_lookup([FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.VOL_DISS, + FilingKey.SPECIAL_RESOLUTION])), + ("general_user_corps", True, LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], + expected_lookup([FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.VOL_DISS, + FilingKey.TRANSITION])), + ("general_user_llc", True, LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], []), + ("general_user_firms", True, LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], + expected_lookup([FilingKey.CHANGE_OF_REGISTRATION, + FilingKey.VOL_DISS_FIRMS])), + + # historical business - staff user ( - "staff_active_cp", + "staff_historical_cp", True, - LegalEntity.State.ACTIVE, + LegalEntity.State.HISTORICAL, ["CP"], "staff", [STAFF_ROLE], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.AR_CP, - FilingKey.COA_CP, - FilingKey.COD_CP, - FilingKey.CORRCTN, - FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, - FilingKey.ADM_DISS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.SPECIAL_RESOLUTION, - ] - ), + expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), ), ( - "staff_active_corps", + "staff_historical_corps", True, - LegalEntity.State.ACTIVE, + LegalEntity.State.HISTORICAL, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], expected_lookup( [ - FilingKey.ADMN_FRZE, - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.CORRCTN, FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, - FilingKey.ADM_DISS, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, + FilingKey.RESTRN_FULL_CORPS, + FilingKey.RESTRN_LTD_CORPS, ] ), ), - ("staff_active_llc", True, LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], []), + ("staff_historical_llc", True, LegalEntity.State.HISTORICAL, ["LLC"], "staff", [STAFF_ROLE], []), ( - "staff_active_firms", + "staff_historical_firms", True, - LegalEntity.State.ACTIVE, + LegalEntity.State.HISTORICAL, ["SP", "GP"], "staff", [STAFF_ROLE], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.CHANGE_OF_REGISTRATION, - FilingKey.CONV_FIRMS, - FilingKey.CORRCTN_FIRMS, - FilingKey.COURT_ORDER, - FilingKey.VOL_DISS_FIRMS, - FilingKey.ADM_DISS_FIRMS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - ] - ), - ), - # active business - general user - ( - "general_user_cp", - True, - LegalEntity.State.ACTIVE, - ["CP"], - "general", - [BASIC_USER], - expected_lookup( - [FilingKey.AR_CP, FilingKey.COA_CP, FilingKey.COD_CP, FilingKey.VOL_DISS, FilingKey.SPECIAL_RESOLUTION] - ), + expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), ), + # historical business - general user + ("general_user_historical_cp", True, LegalEntity.State.HISTORICAL, ["CP"], "general", [BASIC_USER], []), ( - "general_user_corps", + "general_user_historical_corps", True, - LegalEntity.State.ACTIVE, + LegalEntity.State.HISTORICAL, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], - expected_lookup( - [ - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.VOL_DISS, - FilingKey.TRANSITION, - ] - ), + [], ), - ("general_user_llc", True, LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], []), + ("general_user_historical_llc", True, LegalEntity.State.HISTORICAL, ["LLC"], "general", [BASIC_USER], []), ( - "general_user_firms", + "general_user_historical_firms", True, - LegalEntity.State.ACTIVE, + LegalEntity.State.HISTORICAL, ["SP", "GP"], "general", [BASIC_USER], - expected_lookup([FilingKey.CHANGE_OF_REGISTRATION, FilingKey.VOL_DISS_FIRMS]), + [], ), + ], +) +def test_get_allowed_actions( + monkeypatch, app, session, jwt, test_name, business_exists, state, legal_types, username, roles, expected +): + """Assert that get_allowed_actions returns the expected allowable filing info.""" + token = helper_create_jwt(jwt, roles=roles, username=username) + headers = {"Authorization": "Bearer " + token} + + def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods + return headers[one] + + with app.test_request_context(): + monkeypatch.setattr("flask.request.headers.get", mock_auth) + + for legal_type in legal_types: + legal_entity = None + if business_exists: + legal_entity = create_business(legal_type, state) + result = get_allowable_actions(jwt, legal_entity) + assert result + assert result["filing"]["filingSubmissionLink"] + assert result["filing"]["filingTypes"] == expected + + +@pytest.mark.parametrize( + "test_name,business_exists,state,legal_types,username,roles,expected", + [ + # no business - staff user + ("staff_no_business_cp", False, LegalEntity.State.ACTIVE, ["CP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.IA_CP])), + ("staff_no_business_bc", False, LegalEntity.State.ACTIVE, ["BC"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.IA_BC])), + ("staff_no_business_ben", False, LegalEntity.State.ACTIVE, ["BEN"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.IA_BEN])), + ("staff_no_business_cc", False, LegalEntity.State.ACTIVE, ["CC"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.IA_CC])), + ("staff_no_business_ulc", False, LegalEntity.State.ACTIVE, ["ULC"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.IA_ULC])), + ("staff_no_business_llc", False, LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], []), + ("staff_no_business_sp", False, LegalEntity.State.ACTIVE, ["SP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.REG_SP])), + ("staff_no_business_gp", False, LegalEntity.State.ACTIVE, ["GP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.REG_GP])), + + # no business - general user + ("general_user_no_business_cp", False, LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], + expected_lookup([FilingKey.IA_CP])), + ("general_user_no_business_bc", False, LegalEntity.State.ACTIVE, ["BC"], "general", [BASIC_USER], + expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.IA_BC])), + ("general_user_no_business_ben", False, LegalEntity.State.ACTIVE, ["BEN"], "general", [BASIC_USER], + expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.IA_BEN])), + ("general_user_no_business_cc", False, LegalEntity.State.ACTIVE, ["CC"], "general", [BASIC_USER], + expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.IA_CC])), + ("general_user_no_business_ulc", False, LegalEntity.State.ACTIVE, ["ULC"], "general", [BASIC_USER], + expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.IA_ULC])), + ("general_user_no_business_llc", False, LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], []), + ("general_user_no_business_sp", False, LegalEntity.State.ACTIVE, ["SP"], "general", [BASIC_USER], + expected_lookup([FilingKey.REG_SP])), + ("general_user_no_business_gp", False, LegalEntity.State.ACTIVE, ["GP"], "general", [BASIC_USER], + expected_lookup([FilingKey.REG_GP])), + + # active business - staff user + ("staff_active_cp", True, LegalEntity.State.ACTIVE, ["CP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.SPECIAL_RESOLUTION])), + ("staff_active_corps", True, LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + ("staff_active_llc", True, LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], []), + ("staff_active_firms", True, LegalEntity.State.ACTIVE, ["SP", "GP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.CHANGE_OF_REGISTRATION, + FilingKey.CONV_FIRMS, + FilingKey.CORRCTN_FIRMS, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS_FIRMS, + FilingKey.ADM_DISS_FIRMS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + + # active business - general user + ("general_user_cp", True, LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], + expected_lookup([FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.VOL_DISS, + FilingKey.SPECIAL_RESOLUTION])), + ("general_user_corps", True, LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], + expected_lookup([FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.VOL_DISS, + FilingKey.TRANSITION])), + ("general_user_llc", True, LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], []), + ("general_user_firms", True, LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], + expected_lookup([FilingKey.CHANGE_OF_REGISTRATION, + FilingKey.VOL_DISS_FIRMS])), + # historical business - staff user ( "staff_historical_cp", @@ -1724,10 +1868,10 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ), ], ) -def test_get_allowed_actions( +def test_get_allowed_filings( monkeypatch, app, session, jwt, test_name, business_exists, state, legal_types, username, roles, expected ): - """Assert that get_allowed_actions returns the expected allowable filing info.""" + """Assert that get allowed returns valid filings.""" token = helper_create_jwt(jwt, roles=roles, username=username) headers = {"Authorization": "Bearer " + token} @@ -1741,145 +1885,13 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me legal_entity = None if business_exists: legal_entity = create_business(legal_type, state) - result = get_allowable_actions(jwt, legal_entity) - assert result - assert result["filing"]["filingSubmissionLink"] - assert result["filing"]["filingTypes"] == expected + filing_types = get_allowed_filings(legal_entity, state, legal_type, jwt) + assert filing_types == expected @pytest.mark.parametrize( "test_name,business_exists,state,legal_types,username,roles,expected", [ - # no business - staff user - ( - "staff_no_business_cp", - False, - LegalEntity.State.ACTIVE, - ["CP"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.IA_CP]), - ), - ( - "staff_no_business_bc", - False, - LegalEntity.State.ACTIVE, - ["BC"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.IA_BC]), - ), - ( - "staff_no_business_ben", - False, - LegalEntity.State.ACTIVE, - ["BEN"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.IA_BEN]), - ), - ( - "staff_no_business_cc", - False, - LegalEntity.State.ACTIVE, - ["CC"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.IA_CC]), - ), - ( - "staff_no_business_ulc", - False, - LegalEntity.State.ACTIVE, - ["ULC"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.IA_ULC]), - ), - ("staff_no_business_llc", False, LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], []), - ( - "staff_no_business_sp", - False, - LegalEntity.State.ACTIVE, - ["SP"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.REG_SP]), - ), - ( - "staff_no_business_gp", - False, - LegalEntity.State.ACTIVE, - ["GP"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.REG_GP]), - ), - # no business - general user - ( - "general_user_no_business_cp", - False, - LegalEntity.State.ACTIVE, - ["CP"], - "general", - [BASIC_USER], - expected_lookup([FilingKey.IA_CP]), - ), - ( - "general_user_no_business_bc", - False, - LegalEntity.State.ACTIVE, - ["BC"], - "general", - [BASIC_USER], - expected_lookup([FilingKey.IA_BC]), - ), - ( - "general_user_no_business_ben", - False, - LegalEntity.State.ACTIVE, - ["BEN"], - "general", - [BASIC_USER], - expected_lookup([FilingKey.IA_BEN]), - ), - ( - "general_user_no_business_cc", - False, - LegalEntity.State.ACTIVE, - ["CC"], - "general", - [BASIC_USER], - expected_lookup([FilingKey.IA_CC]), - ), - ( - "general_user_no_business_ulc", - False, - LegalEntity.State.ACTIVE, - ["ULC"], - "general", - [BASIC_USER], - expected_lookup([FilingKey.IA_ULC]), - ), - ("general_user_no_business_llc", False, LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], []), - ( - "general_user_no_business_sp", - False, - LegalEntity.State.ACTIVE, - ["SP"], - "general", - [BASIC_USER], - expected_lookup([FilingKey.REG_SP]), - ), - ( - "general_user_no_business_gp", - False, - LegalEntity.State.ACTIVE, - ["GP"], - "general", - [BASIC_USER], - expected_lookup([FilingKey.REG_GP]), - ), # active business - staff user ( "staff_active_cp", @@ -1891,16 +1903,10 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me expected_lookup( [ FilingKey.ADMN_FRZE, - FilingKey.AR_CP, - FilingKey.COA_CP, - FilingKey.COD_CP, - FilingKey.CORRCTN, FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, FilingKey.ADM_DISS, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER, - FilingKey.SPECIAL_RESOLUTION, ] ), ), @@ -1914,14 +1920,7 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me expected_lookup( [ FilingKey.ADMN_FRZE, - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.CORRCTN, FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, FilingKey.ADM_DISS, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER, @@ -1940,11 +1939,8 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me expected_lookup( [ FilingKey.ADMN_FRZE, - FilingKey.CHANGE_OF_REGISTRATION, FilingKey.CONV_FIRMS, - FilingKey.CORRCTN_FIRMS, FilingKey.COURT_ORDER, - FilingKey.VOL_DISS_FIRMS, FilingKey.ADM_DISS_FIRMS, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER, @@ -1952,17 +1948,7 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ), ), # active business - general user - ( - "general_user_cp", - True, - LegalEntity.State.ACTIVE, - ["CP"], - "general", - [BASIC_USER], - expected_lookup( - [FilingKey.AR_CP, FilingKey.COA_CP, FilingKey.COD_CP, FilingKey.VOL_DISS, FilingKey.SPECIAL_RESOLUTION] - ), - ), + ("general_user_cp", True, LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], []), ( "general_user_corps", True, @@ -1970,28 +1956,10 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], - expected_lookup( - [ - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.VOL_DISS, - FilingKey.TRANSITION, - ] - ), + expected_lookup([FilingKey.TRANSITION]), ), ("general_user_llc", True, LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], []), - ( - "general_user_firms", - True, - LegalEntity.State.ACTIVE, - ["SP", "GP"], - "general", - [BASIC_USER], - expected_lookup([FilingKey.CHANGE_OF_REGISTRATION, FilingKey.VOL_DISS_FIRMS]), - ), + ("general_user_firms", True, LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], []), # historical business - staff user ( "staff_historical_cp", @@ -2052,10 +2020,10 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me ), ], ) -def test_get_allowed_filings( +def test_get_allowed_filings_blocker_admin_freeze( monkeypatch, app, session, jwt, test_name, business_exists, state, legal_types, username, roles, expected ): - """Assert that get allowed returns valid filings.""" + """Assert that get allowed returns valid filings when business is frozen.""" token = helper_create_jwt(jwt, roles=roles, username=username) headers = {"Authorization": "Bearer " + token} @@ -2068,7 +2036,10 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me for legal_type in legal_types: legal_entity = None if business_exists: - legal_entity = create_business(legal_type, state) + identifier = (f"BC{random.SystemRandom().getrandbits(0x58)}")[:9] + legal_entity = factory_legal_entity( + identifier=identifier, entity_type=legal_type, state=state, admin_freeze=True + ) filing_types = get_allowed_filings(legal_entity, state, legal_type, jwt) assert filing_types == expected @@ -2077,137 +2048,90 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me "test_name,business_exists,state,legal_types,username,roles,expected", [ # active business - staff user - ( - "staff_active_cp", - True, - LegalEntity.State.ACTIVE, - ["CP"], - "staff", - [STAFF_ROLE], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.COURT_ORDER, - FilingKey.ADM_DISS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - ] - ), - ), - ( - "staff_active_corps", - True, - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.COURT_ORDER, - FilingKey.ADM_DISS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, - ] - ), - ), + ("staff_active_cp", True, LegalEntity.State.ACTIVE, ["CP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.SPECIAL_RESOLUTION])), + ("staff_active_corps", True, LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), ("staff_active_llc", True, LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], []), - ( - "staff_active_firms", - True, - LegalEntity.State.ACTIVE, - ["SP", "GP"], - "staff", - [STAFF_ROLE], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.CONV_FIRMS, - FilingKey.COURT_ORDER, - FilingKey.ADM_DISS_FIRMS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - ] - ), - ), + ("staff_active_firms", True, LegalEntity.State.ACTIVE, ["SP", "GP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.CHANGE_OF_REGISTRATION, + FilingKey.CONV_FIRMS, + FilingKey.CORRCTN_FIRMS, + FilingKey.COURT_ORDER, + FilingKey.ADM_DISS_FIRMS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + # active business - general user - ("general_user_cp", True, LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], []), - ( - "general_user_corps", - True, - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "general", - [BASIC_USER], - expected_lookup([FilingKey.TRANSITION]), - ), + ("general_user_cp", True, LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], + expected_lookup([FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.SPECIAL_RESOLUTION])), + ("general_user_corps", True, LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], + expected_lookup([FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.TRANSITION])), ("general_user_llc", True, LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], []), - ("general_user_firms", True, LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], []), + ("general_user_firms", True, LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], + expected_lookup([FilingKey.CHANGE_OF_REGISTRATION])), + # historical business - staff user - ( - "staff_historical_cp", - True, - LegalEntity.State.HISTORICAL, - ["CP"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), - ), - ( - "staff_historical_corps", - True, - LegalEntity.State.HISTORICAL, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - expected_lookup( - [ - FilingKey.COURT_ORDER, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.RESTRN_FULL_CORPS, - FilingKey.RESTRN_LTD_CORPS, - ] - ), - ), + ("staff_historical_cp", True, LegalEntity.State.HISTORICAL, ["CP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + ("staff_historical_corps", True, LegalEntity.State.HISTORICAL, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.RESTRN_FULL_CORPS, + FilingKey.RESTRN_LTD_CORPS])), ("staff_historical_llc", True, LegalEntity.State.HISTORICAL, ["LLC"], "staff", [STAFF_ROLE], []), - ( - "staff_historical_firms", - True, - LegalEntity.State.HISTORICAL, - ["SP", "GP"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), - ), + ("staff_historical_firms", True, LegalEntity.State.HISTORICAL, ["SP", "GP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + # historical business - general user ("general_user_historical_cp", True, LegalEntity.State.HISTORICAL, ["CP"], "general", [BASIC_USER], []), - ( - "general_user_historical_corps", - True, - LegalEntity.State.HISTORICAL, - ["BC", "BEN", "CC", "ULC"], - "general", - [BASIC_USER], - [], - ), + ("general_user_historical_corps", True, LegalEntity.State.HISTORICAL, ["BC", "BEN", "CC", "ULC"], "general", + [BASIC_USER], []), ("general_user_historical_llc", True, LegalEntity.State.HISTORICAL, ["LLC"], "general", [BASIC_USER], []), - ( - "general_user_historical_firms", - True, - LegalEntity.State.HISTORICAL, - ["SP", "GP"], - "general", - [BASIC_USER], - [], - ), - ], + ("general_user_historical_firms", True, LegalEntity.State.HISTORICAL, ["SP", "GP"], "general", [BASIC_USER], []), + ] ) -def test_get_allowed_filings_blocker_admin_freeze( - monkeypatch, app, session, jwt, test_name, business_exists, state, legal_types, username, roles, expected -): - """Assert that get allowed returns valid filings when business is frozen.""" +def test_get_allowed_filings_blocker_not_in_good_standing(monkeypatch, app, session, jwt, test_name, business_exists, state, + legal_types, username, roles, expected): + """Assert that get allowed returns valid filings when business is not in good standing.""" token = helper_create_jwt(jwt, roles=roles, username=username) headers = {"Authorization": "Bearer " + token} @@ -2218,87 +2142,54 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me monkeypatch.setattr("flask.request.headers.get", mock_auth) for legal_type in legal_types: - legal_entity = None - if business_exists: - identifier = (f"BC{random.SystemRandom().getrandbits(0x58)}")[:9] - legal_entity = factory_legal_entity( - identifier=identifier, entity_type=legal_type, state=state, admin_freeze=True - ) - filing_types = get_allowed_filings(legal_entity, state, legal_type, jwt) - assert filing_types == expected + business = None + identifier = (f"BC{random.SystemRandom().getrandbits(0x58)}")[:9] + business = factory_legal_entity(identifier=identifier, + entity_type=legal_type, + state=state) + with patch.object(type(business), "good_standing", new_callable=PropertyMock) as mock_good_standing: + mock_good_standing.return_value = False + filing_types = get_allowed_filings(business, state, legal_type, jwt) + assert filing_types == expected @pytest.mark.parametrize( "test_name,state,legal_types,username,roles,filing_statuses,expected", [ # active business - staff user - ( - "staff_active_cp", - LegalEntity.State.ACTIVE, - ["CP"], - "staff", - [STAFF_ROLE], - BLOCKER_FILING_STATUSES, - expected_lookup( - [FilingKey.ADMN_FRZE, FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER] - ), - ), - ( - "staff_active_corps", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - BLOCKER_FILING_STATUSES, - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.COURT_ORDER, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, - ] - ), - ), + ("staff_active_cp", LegalEntity.State.ACTIVE, ["CP"], "staff", [STAFF_ROLE], BLOCKER_FILING_STATUSES, + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + ("staff_active_corps", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], + BLOCKER_FILING_STATUSES, + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), ("staff_active_llc", LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], BLOCKER_FILING_STATUSES, []), - ( - "staff_active_firms", - LegalEntity.State.ACTIVE, - ["SP", "GP"], - "staff", - [STAFF_ROLE], - BLOCKER_FILING_STATUSES, - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.CONV_FIRMS, - FilingKey.COURT_ORDER, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - ] - ), - ), + ("staff_active_firms", LegalEntity.State.ACTIVE, ["SP", "GP"], "staff", [STAFF_ROLE], BLOCKER_FILING_STATUSES, + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.CONV_FIRMS, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + # active business - general user ("general_user_cp", LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], BLOCKER_FILING_STATUSES, []), - ( - "general_user_corps", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "general", - [BASIC_USER], - BLOCKER_FILING_STATUSES, - expected_lookup([FilingKey.TRANSITION]), - ), + ("general_user_corps", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], + BLOCKER_FILING_STATUSES, expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.TRANSITION, ])), ("general_user_llc", LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], BLOCKER_FILING_STATUSES, []), - ( - "general_user_firms", - LegalEntity.State.ACTIVE, - ["SP", "GP"], - "general", - [BASIC_USER], - BLOCKER_FILING_STATUSES, - [], - ), + ("general_user_firms", LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], BLOCKER_FILING_STATUSES, + []), # historical business - staff user ( "staff_historical_cp", @@ -2414,21 +2305,58 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me "test_name,state,legal_types,username,roles,filing_types,filing_statuses,expected", [ # active business - staff user + ("staff_active_cp", LegalEntity.State.ACTIVE, ["CP"], "staff", [STAFF_ROLE], + BLOCKER_FILING_TYPES, BLOCKER_FILING_STATUSES_AND_ADDITIONAL, + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + ("staff_active_corps", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], + BLOCKER_FILING_TYPES, BLOCKER_FILING_STATUSES_AND_ADDITIONAL, + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + ("staff_active_llc", LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], + BLOCKER_FILING_TYPES, BLOCKER_FILING_STATUSES_AND_ADDITIONAL, []), + ("staff_active_firms", LegalEntity.State.ACTIVE, ["SP", "GP"], "staff", [STAFF_ROLE], + BLOCKER_FILING_TYPES, BLOCKER_FILING_STATUSES_AND_ADDITIONAL, + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.CONV_FIRMS, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + # active business - general user + ("general_user_cp", LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], + BLOCKER_FILING_TYPES, BLOCKER_FILING_STATUSES_AND_ADDITIONAL, []), + ("general_user_corps", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], + BLOCKER_FILING_TYPES, BLOCKER_FILING_STATUSES_AND_ADDITIONAL, + expected_lookup([FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.TRANSITION])), + ("general_user_llc", LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], BLOCKER_FILING_TYPES, + BLOCKER_FILING_STATUSES_AND_ADDITIONAL, []), + ("general_user_firms", LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], BLOCKER_FILING_TYPES, + BLOCKER_FILING_STATUSES_AND_ADDITIONAL, []), + # historical business - staff user ( - "staff_active_cp", - LegalEntity.State.ACTIVE, + "staff_historical_cp", + LegalEntity.State.HISTORICAL, ["CP"], "staff", [STAFF_ROLE], BLOCKER_FILING_TYPES, BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - expected_lookup( - [FilingKey.ADMN_FRZE, FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER] - ), + expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), ), ( - "staff_active_corps", - LegalEntity.State.ACTIVE, + "staff_historical_corps", + LegalEntity.State.HISTORICAL, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], @@ -2436,17 +2364,17 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me BLOCKER_FILING_STATUSES_AND_ADDITIONAL, expected_lookup( [ - FilingKey.ADMN_FRZE, FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, + FilingKey.RESTRN_FULL_CORPS, + FilingKey.RESTRN_LTD_CORPS, ] ), ), ( - "staff_active_llc", - LegalEntity.State.ACTIVE, + "staff_historical_llc", + LegalEntity.State.HISTORICAL, ["LLC"], "staff", [STAFF_ROLE], @@ -2455,27 +2383,19 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me [], ), ( - "staff_active_firms", - LegalEntity.State.ACTIVE, + "staff_historical_firms", + LegalEntity.State.HISTORICAL, ["SP", "GP"], "staff", [STAFF_ROLE], BLOCKER_FILING_TYPES, BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.CONV_FIRMS, - FilingKey.COURT_ORDER, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - ] - ), + expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), ), - # active business - general user + # historical business - general user ( - "general_user_cp", - LegalEntity.State.ACTIVE, + "general_user_historical_cp", + LegalEntity.State.HISTORICAL, ["CP"], "general", [BASIC_USER], @@ -2484,18 +2404,18 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me [], ), ( - "general_user_corps", - LegalEntity.State.ACTIVE, + "general_user_historical_corps", + LegalEntity.State.HISTORICAL, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], BLOCKER_FILING_TYPES, BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - expected_lookup([FilingKey.TRANSITION]), + [], ), ( - "general_user_llc", - LegalEntity.State.ACTIVE, + "general_user_historical_llc", + LegalEntity.State.HISTORICAL, ["LLC"], "general", [BASIC_USER], @@ -2504,98 +2424,8 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me [], ), ( - "general_user_firms", - LegalEntity.State.ACTIVE, - ["SP", "GP"], - "general", - [BASIC_USER], - BLOCKER_FILING_TYPES, - BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - [], - ), - # historical business - staff user - ( - "staff_historical_cp", - LegalEntity.State.HISTORICAL, - ["CP"], - "staff", - [STAFF_ROLE], - BLOCKER_FILING_TYPES, - BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), - ), - ( - "staff_historical_corps", - LegalEntity.State.HISTORICAL, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - BLOCKER_FILING_TYPES, - BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - expected_lookup( - [ - FilingKey.COURT_ORDER, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.RESTRN_FULL_CORPS, - FilingKey.RESTRN_LTD_CORPS, - ] - ), - ), - ( - "staff_historical_llc", - LegalEntity.State.HISTORICAL, - ["LLC"], - "staff", - [STAFF_ROLE], - BLOCKER_FILING_TYPES, - BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - [], - ), - ( - "staff_historical_firms", - LegalEntity.State.HISTORICAL, - ["SP", "GP"], - "staff", - [STAFF_ROLE], - BLOCKER_FILING_TYPES, - BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), - ), - # historical business - general user - ( - "general_user_historical_cp", - LegalEntity.State.HISTORICAL, - ["CP"], - "general", - [BASIC_USER], - BLOCKER_FILING_TYPES, - BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - [], - ), - ( - "general_user_historical_corps", - LegalEntity.State.HISTORICAL, - ["BC", "BEN", "CC", "ULC"], - "general", - [BASIC_USER], - BLOCKER_FILING_TYPES, - BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - [], - ), - ( - "general_user_historical_llc", - LegalEntity.State.HISTORICAL, - ["LLC"], - "general", - [BASIC_USER], - BLOCKER_FILING_TYPES, - BLOCKER_FILING_STATUSES_AND_ADDITIONAL, - [], - ), - ( - "general_user_historical_firms", - LegalEntity.State.HISTORICAL, + "general_user_historical_firms", + LegalEntity.State.HISTORICAL, ["SP", "GP"], "general", [BASIC_USER], @@ -2650,155 +2480,30 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me @pytest.mark.parametrize( - "test_name,state,legal_types,username,roles,expected", + "test_name,state,legal_types,username,roles,filing_types,filing_statuses,is_fed,expected", [ # active business - staff user - ( - "staff_active_cp", - LegalEntity.State.ACTIVE, - ["CP"], - "staff", - [STAFF_ROLE], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.AR_CP, - FilingKey.COA_CP, - FilingKey.COD_CP, - FilingKey.CORRCTN, - FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, - FilingKey.ADM_DISS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.SPECIAL_RESOLUTION, - ] - ), - ), - ( - "staff_active_corps", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.CORRCTN, - FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, - FilingKey.ADM_DISS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, - ] - ), - ), - ("staff_active_llc", LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], []), - ( - "staff_active_firms", - LegalEntity.State.ACTIVE, - ["SP", "GP"], - "staff", - [STAFF_ROLE], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.CONV_FIRMS, - FilingKey.COURT_ORDER, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - ] - ), - ), + ("staff_active_corps", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], + ["dissolution.voluntary", "dissolution.administrative"], BLOCKER_DISSOLUTION_STATUSES_FOR_AMALG, True, + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + # active business - general user - ( - "general_user_cp", - LegalEntity.State.ACTIVE, - ["CP"], - "general", - [BASIC_USER], - expected_lookup( - [FilingKey.AR_CP, FilingKey.COA_CP, FilingKey.COD_CP, FilingKey.VOL_DISS, FilingKey.SPECIAL_RESOLUTION] - ), - ), - ( - "general_user_corps", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "general", - [BASIC_USER], - expected_lookup( - [ - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.VOL_DISS, - FilingKey.TRANSITION, - ] - ), - ), - ("general_user_llc", LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], []), - ("general_user_firms", LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], []), - # historical business - staff user - ( - "staff_historical_cp", - LegalEntity.State.HISTORICAL, - ["CP"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), - ), - ( - "staff_historical_corps", - LegalEntity.State.HISTORICAL, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - expected_lookup( - [ - FilingKey.COURT_ORDER, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.RESTRN_FULL_CORPS, - FilingKey.RESTRN_LTD_CORPS, - ] - ), - ), - ("staff_historical_llc", LegalEntity.State.HISTORICAL, ["LLC"], "staff", [STAFF_ROLE], []), - ( - "staff_historical_firms", - LegalEntity.State.HISTORICAL, - ["SP", "GP"], - "staff", - [STAFF_ROLE], - expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), - ), - # historical business - general user - ("general_user_historical_cp", LegalEntity.State.HISTORICAL, ["CP"], "general", [BASIC_USER], []), - ( - "general_user_historical_corps", - LegalEntity.State.HISTORICAL, - ["BC", "BEN", "CC", "ULC"], - "general", - [BASIC_USER], - [], - ), - ("general_user_historical_llc", LegalEntity.State.HISTORICAL, ["LLC"], "general", [BASIC_USER], []), - ("general_user_historical_firms", LegalEntity.State.HISTORICAL, ["SP", "GP"], "general", [BASIC_USER], []), - ], + ("general_user_corps", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], + ["dissolution.voluntary", "dissolution.administrative"], BLOCKER_DISSOLUTION_STATUSES_FOR_AMALG, True, + expected_lookup([FilingKey.TRANSITION])) + ] ) -def test_allowed_filings_warnings( - monkeypatch, app, session, jwt, test_name, state, legal_types, username, roles, expected -): - """Assert that get allowed returns valid filings when business has warnings.""" +def test_allowed_filings_blocker_filing_amalgamations(monkeypatch, app, session, jwt, test_name, state, + legal_types, username, roles, filing_types, filing_statuses, + is_fed, expected): + """Assert that get allowed returns valid filings when amalgamating business has blocker filings. + + A blocker filing in this instance is a pending future effective dissolution filing. + """ token = helper_create_jwt(jwt, roles=roles, username=username) headers = {"Authorization": "Bearer " + token} @@ -2807,191 +2512,266 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me with app.test_request_context(): monkeypatch.setattr("flask.request.headers.get", mock_auth) + for legal_type in legal_types: - legal_entity = create_business(legal_type, state) - if legal_type in ("SP", "GP") and state == LegalEntity.State.ACTIVE: - legal_entity.warnings = MISSING_BUSINESS_INFO_WARNINGS - filing_types = get_allowed_filings(legal_entity, state, legal_type, jwt) - assert filing_types == expected + for filing_status in filing_statuses: + for filing in filing_types: + filing_type, filing_sub_type = filing.split(".") + business = create_business(legal_type, state) + filing_dict = FILING_DATA.get(filing_type, None) + create_incomplete_filing(business=business, + filing_name=filing_type, + filing_status=filing_status, + filing_dict=filing_dict, + filing_type=filing_type, + filing_sub_type=filing_sub_type, + is_future_effective=is_fed) + allowed_filing_types = get_allowed_filings(business, state, legal_type, jwt) + assert allowed_filing_types == expected @pytest.mark.parametrize( - "test_name,state,legal_types,username,roles,state_filing_types,state_filing_sub_types,expected", + "test_name,state,legal_types,username,roles,expected", [ # active business - staff user + ("staff_active_cp", LegalEntity.State.ACTIVE, ["CP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.SPECIAL_RESOLUTION])), + ("staff_active_corps", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + ("staff_active_llc", LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], []), + ("staff_active_firms", LegalEntity.State.ACTIVE, ["SP", "GP"], "staff", [STAFF_ROLE], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.CONV_FIRMS, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + + # active business - general user + ("general_user_cp", LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], + expected_lookup([FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.VOL_DISS, + FilingKey.SPECIAL_RESOLUTION])), + ("general_user_corps", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], + expected_lookup([FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.VOL_DISS, + FilingKey.TRANSITION])), + ("general_user_llc", LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], []), + ("general_user_firms", LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], []), + # historical business - staff user ( - "staff_active_cp_unaffected", - LegalEntity.State.ACTIVE, + "staff_historical_cp", + LegalEntity.State.HISTORICAL, ["CP"], "staff", [STAFF_ROLE], - ["restoration", "restoration", None, "restoration"], - ["limitedRestoration", "limitedRestorationExtension", None, "fullRestoration"], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.AR_CP, - FilingKey.COA_CP, - FilingKey.COD_CP, - FilingKey.CORRCTN, - FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, - FilingKey.ADM_DISS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.SPECIAL_RESOLUTION, - ] - ), - ), - ( - "staff_active_corps_valid_state_filing_success", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - ["restoration", "restoration"], - ["limitedRestoration", "limitedRestorationExtension"], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.CORRCTN, - FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, - FilingKey.ADM_DISS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, - FilingKey.RESTRN_LTD_EXT_CORPS, - FilingKey.RESTRN_LTD_TO_FULL_CORPS, - ] - ), + expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), ), ( - "staff_active_corps_valid_state_filing_fail", - LegalEntity.State.ACTIVE, + "staff_historical_corps", + LegalEntity.State.HISTORICAL, ["BC", "BEN", "CC", "ULC"], "staff", [STAFF_ROLE], - [None, "restoration"], - [None, "fullRestoration"], expected_lookup( [ - FilingKey.ADMN_FRZE, - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.CORRCTN, FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, - FilingKey.ADM_DISS, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, + FilingKey.RESTRN_FULL_CORPS, + FilingKey.RESTRN_LTD_CORPS, ] ), ), + ("staff_historical_llc", LegalEntity.State.HISTORICAL, ["LLC"], "staff", [STAFF_ROLE], []), ( - "staff_active_llc_valid_state_filing_success", - LegalEntity.State.ACTIVE, - ["LLC"], - "staff", - [STAFF_ROLE], - ["restoration", "restoration"], - ["limitedRestoration", "limitedRestorationExtension"], - [], - ), - ( - "staff_active_llc_valid_state_filing_fail", - LegalEntity.State.ACTIVE, - ["LLC"], - "staff", - [STAFF_ROLE], - [None, "restoration"], - [None, "fullRestoration"], - [], - ), - ( - "staff_active_firms_unaffected", - LegalEntity.State.ACTIVE, + "staff_historical_firms", + LegalEntity.State.HISTORICAL, ["SP", "GP"], - "staff", - [STAFF_ROLE], - ["putBackOn", None], - [None, None], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.CHANGE_OF_REGISTRATION, - FilingKey.CONV_FIRMS, - FilingKey.CORRCTN_FIRMS, - FilingKey.COURT_ORDER, - FilingKey.VOL_DISS_FIRMS, - FilingKey.ADM_DISS_FIRMS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - ] - ), - ), - # active business - general user - ( - "general_user_cp_unaffected", - LegalEntity.State.ACTIVE, - ["CP"], - "general", - [BASIC_USER], - ["restoration", "restoration", None, "restoration"], - ["limitedRestoration", "limitedRestorationExtension", None, "fullRestoration"], - expected_lookup( - [FilingKey.AR_CP, FilingKey.COA_CP, FilingKey.COD_CP, FilingKey.VOL_DISS, FilingKey.SPECIAL_RESOLUTION] - ), - ), - ( - "general_user_corps_unaffected", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "general", - [BASIC_USER], - ["restoration", "restoration", None, "restoration"], - ["limitedRestoration", "limitedRestorationExtension", None, "fullRestoration"], - expected_lookup( - [ - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.VOL_DISS, - FilingKey.TRANSITION, - ] - ), - ), - ( - "general_user_llc_unaffected", - LegalEntity.State.ACTIVE, - ["LLC"], - "general", - [BASIC_USER], - ["restoration", "restoration", None, "restoration"], - ["limitedRestoration", "limitedRestorationExtension", None, "fullRestoration"], - [], + "staff", + [STAFF_ROLE], + expected_lookup([FilingKey.COURT_ORDER, FilingKey.REGISTRARS_NOTATION, FilingKey.REGISTRARS_ORDER]), ), + # historical business - general user + ("general_user_historical_cp", LegalEntity.State.HISTORICAL, ["CP"], "general", [BASIC_USER], []), ( - "general_user_firms_unaffected", - LegalEntity.State.ACTIVE, - ["SP", "GP"], + "general_user_historical_corps", + LegalEntity.State.HISTORICAL, + ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], - ["putBackOn", None], - [None, None], - expected_lookup([FilingKey.CHANGE_OF_REGISTRATION, FilingKey.VOL_DISS_FIRMS]), + [], ), + ("general_user_historical_llc", LegalEntity.State.HISTORICAL, ["LLC"], "general", [BASIC_USER], []), + ("general_user_historical_firms", LegalEntity.State.HISTORICAL, ["SP", "GP"], "general", [BASIC_USER], []), + ], +) +def test_allowed_filings_warnings( + monkeypatch, app, session, jwt, test_name, state, legal_types, username, roles, expected +): + """Assert that get allowed returns valid filings when business has warnings.""" + token = helper_create_jwt(jwt, roles=roles, username=username) + headers = {"Authorization": "Bearer " + token} + + def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods + return headers[one] + + with app.test_request_context(): + monkeypatch.setattr("flask.request.headers.get", mock_auth) + for legal_type in legal_types: + legal_entity = create_business(legal_type, state) + if legal_type in ("SP", "GP") and state == LegalEntity.State.ACTIVE: + legal_entity.warnings = MISSING_BUSINESS_INFO_WARNINGS + filing_types = get_allowed_filings(legal_entity, state, legal_type, jwt) + assert filing_types == expected + + +@pytest.mark.parametrize( + "test_name,state,legal_types,username,roles,state_filing_types,state_filing_sub_types,expected", + [ + # active business - staff user + ("staff_active_cp_unaffected", LegalEntity.State.ACTIVE, ["CP"], "staff", [STAFF_ROLE], + ["restoration", "restoration", None, "restoration"], + ["limitedRestoration", "limitedRestorationExtension", None, "fullRestoration"], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.SPECIAL_RESOLUTION])), + + ("staff_active_corps_valid_state_filing_success", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", + [STAFF_ROLE], ["restoration", "restoration"], ["limitedRestoration", "limitedRestorationExtension"], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION, + FilingKey.RESTRN_LTD_EXT_CORPS, + FilingKey.RESTRN_LTD_TO_FULL_CORPS])), + ("staff_active_corps_valid_state_filing_fail", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", + [STAFF_ROLE], [None, "restoration"], [None, "fullRestoration"], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + ("staff_active_llc_valid_state_filing_success", LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], + ["restoration", "restoration"], ["limitedRestoration", "limitedRestorationExtension"], []), + ("staff_active_llc_valid_state_filing_fail", LegalEntity.State.ACTIVE, ["LLC"], "staff", [STAFF_ROLE], + [None, "restoration"], [None, "fullRestoration"], []), + + ("staff_active_firms_unaffected", LegalEntity.State.ACTIVE, ["SP", "GP"], "staff", [STAFF_ROLE], + ["putBackOn", None], [None, None], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.CHANGE_OF_REGISTRATION, + FilingKey.CONV_FIRMS, + FilingKey.CORRCTN_FIRMS, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS_FIRMS, + FilingKey.ADM_DISS_FIRMS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER])), + + # active business - general user + ("general_user_cp_unaffected", LegalEntity.State.ACTIVE, ["CP"], "general", [BASIC_USER], + ["restoration", "restoration", None, "restoration"], + ["limitedRestoration", "limitedRestorationExtension", None, "fullRestoration"], + expected_lookup([FilingKey.AR_CP, + FilingKey.COA_CP, + FilingKey.COD_CP, + FilingKey.VOL_DISS, + FilingKey.SPECIAL_RESOLUTION])), + ("general_user_corps_unaffected", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "general", [BASIC_USER], + ["restoration", "restoration", None, "restoration"], + ["limitedRestoration", "limitedRestorationExtension", None, "fullRestoration"], + expected_lookup([FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.VOL_DISS, + FilingKey.TRANSITION])), + ("general_user_llc_unaffected", LegalEntity.State.ACTIVE, ["LLC"], "general", [BASIC_USER], + ["restoration", "restoration", None, "restoration"], + ["limitedRestoration", "limitedRestorationExtension", None, "fullRestoration"], []), + ("general_user_firms_unaffected", LegalEntity.State.ACTIVE, ["SP", "GP"], "general", [BASIC_USER], + ["putBackOn", None], [None, None], + expected_lookup([FilingKey.CHANGE_OF_REGISTRATION, + FilingKey.VOL_DISS_FIRMS])), # historical business - staff user ( "staff_historical_cp_unaffected", @@ -3282,101 +3062,69 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me "test_name,state,legal_types,username,roles,filing_types,filing_sub_types,is_completed,expected", [ # active business - staff user - ( - "staff_active_corps_completed_filing_success", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - ["consentContinuationOut", "consentContinuationOut"], - [None, None], - [True, True], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.CONTINUATION_OUT, - FilingKey.CORRCTN, - FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, - FilingKey.ADM_DISS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, - ] - ), - ), - ( - "staff_active_corps_completed_filing_success", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - ["consentContinuationOut", "consentContinuationOut"], - [None, None], - [True, False], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.CONTINUATION_OUT, - FilingKey.COURT_ORDER, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, - ] - ), - ), - ( - "staff_active_corps_completed_filing_fail", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - ["consentContinuationOut", "consentContinuationOut"], - [None, None], - [False, False], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.COURT_ORDER, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, - ] - ), - ), - ( - "staff_active_corps_completed_filing_fail", - LegalEntity.State.ACTIVE, - ["BC", "BEN", "CC", "ULC"], - "staff", - [STAFF_ROLE], - [None, None], - [None, None], - [False, False], - expected_lookup( - [ - FilingKey.ADMN_FRZE, - FilingKey.ALTERATION, - FilingKey.AR_CORPS, - FilingKey.COA_CORPS, - FilingKey.COD_CORPS, - FilingKey.CONSENT_CONTINUATION_OUT, - FilingKey.CORRCTN, - FilingKey.COURT_ORDER, - FilingKey.VOL_DISS, - FilingKey.ADM_DISS, - FilingKey.REGISTRARS_NOTATION, - FilingKey.REGISTRARS_ORDER, - FilingKey.TRANSITION, - ] - ), - ), - ], + ("staff_active_corps_completed_filing_success", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", + [STAFF_ROLE], ["consentContinuationOut", "consentContinuationOut"], [None, None], [True, True], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.CONTINUATION_OUT, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + ("staff_active_corps_completed_filing_success", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", + [STAFF_ROLE], ["consentContinuationOut", "consentContinuationOut"], [None, None], [True, False], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.CONTINUATION_OUT, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + ("staff_active_corps_completed_filing_fail", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", + [STAFF_ROLE], ["consentContinuationOut", "consentContinuationOut"], [None, None], [False, False], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.COURT_ORDER, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + ("staff_active_corps_completed_filing_fail", LegalEntity.State.ACTIVE, ["BC", "BEN", "CC", "ULC"], "staff", + [STAFF_ROLE], [None, None], [None, None], [False, False], + expected_lookup([FilingKey.ADMN_FRZE, + FilingKey.AGM_EXTENSION, + FilingKey.AGM_LOCATION_CHANGE, + FilingKey.ALTERATION, + FilingKey.AMALGAMATION_REGULAR, + FilingKey.AMALGAMATION_VERTICAL, + FilingKey.AMALGAMATION_HORIZONTAL, + FilingKey.AR_CORPS, + FilingKey.COA_CORPS, + FilingKey.COD_CORPS, + FilingKey.CONSENT_CONTINUATION_OUT, + FilingKey.CORRCTN, + FilingKey.COURT_ORDER, + FilingKey.VOL_DISS, + FilingKey.ADM_DISS, + FilingKey.REGISTRARS_NOTATION, + FilingKey.REGISTRARS_ORDER, + FilingKey.TRANSITION])), + ] ) def test_allowed_filings_completed_filing_check( monkeypatch, @@ -3416,19 +3164,358 @@ def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library me create_filing(business, filing_type, filing_sub_type) else: filing_dict = FILING_DATA.get(filing_type, filing_sub_type) - create_incomplete_filing( - legal_entity=business, - filing_name="unknown", - filing_status=Filing.Status.DRAFT.value, - filing_dict=filing_dict, - filing_type=filing_type, - filing_sub_type=filing_sub_type, - ) + create_incomplete_filing(business=business, + filing_name="unknown", + filing_status=Filing.Status.DRAFT.value, + filing_dict=filing_dict, + filing_type=filing_type, + filing_sub_type=filing_sub_type) allowed_filing_types = get_allowed_filings(business, state, legal_type, jwt) assert allowed_filing_types == expected +@patch("legal_api.models.User.find_by_jwt_token", return_value=User(id=1, login_source="BCSC")) +@patch("legal_api.services.authz.is_self_registered_owner_operator", return_value=True) +def test_are_digital_credentials_allowed_false_when_no_token(monkeypatch, app, session, jwt): + token_json = {"username": "test"} + token = helper_create_jwt(jwt, roles=[PUBLIC_USER], username=token_json["username"]) + headers = {"Authorization": "Bearer " + token} + + def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods + return headers[one] + + with app.test_request_context(): + jwt.get_token_auth_header = MagicMock(return_value=token) + pyjwt.decode = MagicMock(return_value=None) + monkeypatch.setattr("flask.request.headers.get", mock_auth) + + business = create_business("SP", LegalEntity.State.ACTIVE) + assert are_digital_credentials_allowed(business, jwt) is False + + +@patch("legal_api.models.User.find_by_jwt_token", return_value=None) +@patch("legal_api.services.authz.is_self_registered_owner_operator", return_value=True) +def test_are_digital_credentials_allowed_false_when_no_user(monkeypatch, app, session, jwt): + token_json = {"username": "test"} + token = helper_create_jwt(jwt, roles=[PUBLIC_USER], username=token_json["username"]) + headers = {"Authorization": "Bearer " + token} + + def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods + return headers[one] + + with app.test_request_context(): + jwt.get_token_auth_header = MagicMock(return_value=token) + pyjwt.decode = MagicMock(return_value=token_json) + monkeypatch.setattr("flask.request.headers.get", mock_auth) + + business = create_business("SP", LegalEntity.State.ACTIVE) + assert are_digital_credentials_allowed(business, jwt) is False + + +@patch("legal_api.models.User.find_by_jwt_token", return_value=User(id=1, login_source="BCSC")) +@patch("legal_api.services.authz.is_self_registered_owner_operator", return_value=True) +def test_are_digital_credentials_allowed_false_when_user_is_staff(monkeypatch, app, session, jwt): + token_json = {"username": "test"} + token = helper_create_jwt(jwt, roles=[STAFF_ROLE], username=token_json["username"]) + headers = {"Authorization": "Bearer " + token} + + def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods + return headers[one] + + with app.test_request_context(): + jwt.get_token_auth_header = MagicMock(return_value=token) + pyjwt.decode = MagicMock(return_value=token_json) + monkeypatch.setattr("flask.request.headers.get", mock_auth) + + business = create_business("SP", LegalEntity.State.ACTIVE) + assert are_digital_credentials_allowed(business, jwt) is False + + +@patch("legal_api.models.User.find_by_jwt_token", return_value=User(id=1, login_source="NOT_BCSC")) +@patch("legal_api.services.authz.is_self_registered_owner_operator", return_value=True) +def test_are_digital_credentials_allowed_false_when_login_source_not_bcsc(monkeypatch, app, session, jwt): + token_json = {"username": "test"} + token = helper_create_jwt(jwt, roles=[PUBLIC_USER], username=token_json["username"]) + headers = {"Authorization": "Bearer " + token} + + def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods + return headers[one] + + with app.test_request_context(): + jwt.get_token_auth_header = MagicMock(return_value=token) + pyjwt.decode = MagicMock(return_value=token_json) + monkeypatch.setattr("flask.request.headers.get", mock_auth) + + business = create_business("SP", LegalEntity.State.ACTIVE) + assert are_digital_credentials_allowed(business, jwt) is False + + +@patch("legal_api.models.User.find_by_jwt_token", return_value=User(id=1, login_source="BCSC")) +@patch("legal_api.services.authz.is_self_registered_owner_operator", return_value=True) +def test_are_digital_credentials_allowed_false_when_wrong_business_type(monkeypatch, app, session, jwt): + token_json = {"username": "test"} + token = helper_create_jwt(jwt, roles=[PUBLIC_USER], username=token_json["username"]) + headers = {"Authorization": "Bearer " + token} + + def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods + return headers[one] + + with app.test_request_context(): + jwt.get_token_auth_header = MagicMock(return_value=token) + pyjwt.decode = MagicMock(return_value=token_json) + monkeypatch.setattr("flask.request.headers.get", mock_auth) + + business = create_business("GP", LegalEntity.State.ACTIVE) + assert are_digital_credentials_allowed(business, jwt) is False + + +@patch("legal_api.models.User.find_by_jwt_token", return_value=User(id=1, login_source="BCSC")) +@patch("legal_api.services.authz.is_self_registered_owner_operator", return_value=False) +def test_are_digital_credentials_allowed_false_when_not_owner_operator(monkeypatch, app, session, jwt): + token_json = {"username": "test"} + token = helper_create_jwt(jwt, roles=[PUBLIC_USER], username=token_json["username"]) + headers = {"Authorization": "Bearer " + token} + + def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods + return headers[one] + + with app.test_request_context(): + jwt.get_token_auth_header = MagicMock(return_value=token) + pyjwt.decode = MagicMock(return_value=token_json) + monkeypatch.setattr("flask.request.headers.get", mock_auth) + + business = create_business("SP", LegalEntity.State.ACTIVE) + assert are_digital_credentials_allowed(business, jwt) is False + + +@patch("legal_api.models.User.find_by_jwt_token", return_value=User(id=1, login_source="BCSC")) +@patch("legal_api.services.authz.is_self_registered_owner_operator", return_value=True) +def test_are_digital_credentials_allowed_true(monkeypatch, app, session, jwt): + token_json = {"username": "test"} + token = helper_create_jwt(jwt, roles=[PUBLIC_USER], username=token_json["username"]) + headers = {"Authorization": "Bearer " + token} + + def mock_auth(one, two): # pylint: disable=unused-argument; mocks of library methods + return headers[one] + + with app.test_request_context(): + jwt.get_token_auth_header = MagicMock(return_value=token) + pyjwt.decode = MagicMock(return_value=token_json) + monkeypatch.setattr("flask.request.headers.get", mock_auth) + + business = create_business("SP", LegalEntity.State.ACTIVE) + assert are_digital_credentials_allowed(business, jwt) is True + + +@patch("legal_api.services.authz.get_registration_filing", return_value=None) +def test_is_self_registered_owner_operator_false_when_no_registration_filing(app, session): + user = factory_user(username="test", firstname="Test", lastname="User") + business = create_business("SP", LegalEntity.State.ACTIVE) + + assert is_self_registered_owner_operator(business, user) is False + + +def test_is_self_registered_owner_operator_false_when_no_proprietors(app, session): + user = factory_user(username="test", firstname="Test", lastname="User") + business = create_business("SP", LegalEntity.State.ACTIVE) + completing_party_role = create_party_role( + EntityRole.RoleTypes.completing_party, + **create_test_user() + ) + filing = factory_completed_filing( + business=business, + data_dict={"filing": {"header": {"name": "registration"}}}, + filing_date=_datetime.utcnow(), filing_type="registration" + ) + filing.filing_party_roles.append(completing_party_role) + filing.submitter_id = user.id + filing.save() + + assert is_self_registered_owner_operator(business, user) is False + + +@patch("legal_api.models.EntityRole.get_parties_by_role", + return_value=[EntityRole(role=EntityRole.RoleTypes.proprietor)]) +def test_is_self_registered_owner_operator_false_when_no_proprietor(app, session): + user = factory_user(username="test", firstname="Test", lastname="User") + business = create_business("SP", LegalEntity.State.ACTIVE) + completing_party_role = create_party_role( + EntityRole.RoleTypes.completing_party, + **create_test_user() + ) + filing = factory_completed_filing( + business=business, + data_dict={"filing": {"header": {"name": "registration"}}}, + filing_date=_datetime.utcnow(), filing_type="registration" + ) + filing.filing_party_roles.append(completing_party_role) + filing.submitter_id = user.id + filing.save() + + assert is_self_registered_owner_operator(business, user) is False + + +@patch("legal_api.models.EntityRole.get_party_roles_by_filing", return_value=None) +def test_is_self_registered_owner_operator_false_when_no_completing_parties(app, session): + user = factory_user(username="test", firstname="Test", lastname="User") + business = create_business("SP", LegalEntity.State.ACTIVE) + proprietor_party_role = create_party_role( + EntityRole.RoleTypes.proprietor, + **create_test_user() + ) + proprietor_party_role.legal_entity_id = business.id + proprietor_party_role.save() + + assert is_self_registered_owner_operator(business, user) is False + + +@patch("legal_api.models.EntityRole.get_party_roles_by_filing", + return_value=[EntityRole(role=EntityRole.RoleTypes.completing_party)]) +def test_is_self_registered_owner_operator_false_when_no_completing_party(app, session): + user = factory_user(username="test", firstname="Test", lastname="User") + business = create_business("SP", LegalEntity.State.ACTIVE) + proprietor_party_role = create_party_role( + EntityRole.RoleTypes.PROPRIETOR, + **create_test_user() + ) + proprietor_party_role.legal_entity_id = business.id + proprietor_party_role.save() + + assert is_self_registered_owner_operator(business, user) is False + + +def test_is_self_registered_owner_operator_false_when_parties_not_matching(app, session): + user = factory_user(username="test", firstname="Test1", lastname="User1") + business = create_business("SP", LegalEntity.State.ACTIVE) + completing_party_role = create_party_role( + EntityRole.RoleTypes.completing_party, + **create_test_user("1") + ) + filing = factory_completed_filing( + business=business, + data_dict={"filing": {"header": {"name": "registration"}}}, + filing_date=_datetime.utcnow(), filing_type="registration" + ) + filing.filing_party_roles.append(completing_party_role) + filing.submitter_id = user.id + filing.save() + + proprietor_party_role = create_party_role( + EntityRole.RoleTypes.proprietor, + **create_test_user("2") + ) + proprietor_party_role.legal_entity_id = business.id + proprietor_party_role.save() + + assert is_self_registered_owner_operator(business, user) is False + + +def test_is_self_registered_owner_operator_false_when_user_not_matching(app, session): + user = factory_user(username="test", firstname="Test1", lastname="User1") + business = create_business("SP", LegalEntity.State.ACTIVE) + completing_party_role = create_party_role( + EntityRole.RoleTypes.completing_party, + **create_test_user("2") + ) + filing = factory_completed_filing( + business=business, + data_dict={"filing": {"header": {"name": "registration"}}}, + filing_date=_datetime.utcnow(), filing_type="registration" + ) + filing.filing_party_roles.append(completing_party_role) + filing.submitter_id = user.id + filing.save() + + proprietor_party_role = create_party_role( + EntityRole.RoleTypes.proprietor, + **create_test_user("2") + ) + proprietor_party_role.legal_entity_id = business.id + proprietor_party_role.save() + + assert is_self_registered_owner_operator(business, user) is False + + +def test_is_self_registered_owner_operator_false_when_proprietor_uses_middle_name_field_and_user_does_not(app, session): + user = factory_user(username="test", firstname="Test", lastname="User") + business = create_business("SP", LegalEntity.State.ACTIVE) + completing_party_role = create_party_role( + EntityRole.RoleTypes.completing_party, + **create_test_user(first_name="TEST", last_name="USER") + ) + filing = factory_completed_filing( + business=business, + data_dict={"filing": {"header": {"name": "registration"}}}, + filing_date=_datetime.utcnow(), filing_type="registration" + ) + filing.filing_party_roles.append(completing_party_role) + filing.submitter_id = user.id + filing.save() + + proprietor_party_role = create_party_role( + EntityRole.RoleTypes.proprietor, + **create_test_user(first_name="TEST", middle_initial="TU", last_name="USER") + ) + proprietor_party_role.legal_entity_id = business.id + proprietor_party_role.save() + + assert is_self_registered_owner_operator(business, user) is False + + +def test_is_self_registered_owner_operator_true_when_proprietor_and_user_uses_middle_name_field(app, session): + user = factory_user(username="test", firstname="Test Tu", lastname="User") + business = create_business("SP", LegalEntity.State.ACTIVE) + completing_party_role = create_party_role( + EntityRole.RoleTypes.completing_party, + **create_test_user(first_name="TEST TU", last_name="USER") + ) + filing = factory_completed_filing( + business=business, + data_dict={"filing": {"header": {"name": "registration"}}}, + filing_date=_datetime.utcnow(), filing_type="registration" + ) + filing.filing_party_roles.append(completing_party_role) + filing.submitter_id = user.id + filing.save() + + proprietor_party_role = create_party_role( + EntityRole.RoleTypes.proprietor, + **create_test_user(first_name="TEST", middle_initial="TU", last_name="USER") + ) + proprietor_party_role.legal_entity_id = business.id + proprietor_party_role.save() + + assert is_self_registered_owner_operator(business, user) is True + + +def test_is_self_registered_owner_operator_true(app, session): + user = factory_user(username="test", firstname="Test", lastname="User") + business = create_business("SP", LegalEntity.State.ACTIVE) + completing_party_role = create_party_role( + EntityRole.RoleTypes.completing_party, + **create_test_user(first_name="TEST", last_name="USER") + ) + filing = factory_completed_filing( + business=business, + data_dict={"filing": {"header": {"name": "registration"}}}, + filing_date=_datetime.utcnow(), filing_type="registration" + ) + filing.filing_party_roles.append(completing_party_role) + filing.submitter_id = user.id + filing.save() + + proprietor_party_role = create_party_role( + EntityRole.RoleTypes.proprietor, + **create_test_user(first_name="TEST", last_name="USER") + ) + proprietor_party_role.legal_entity_id = business.id + proprietor_party_role.save() + proprietor_party_role.party.middle_initial = None + proprietor_party_role.party.save() + assert is_self_registered_owner_operator(business, user) is True + + def create_business(legal_type, state): """Create a business.""" identifier = (f"BC{random.SystemRandom().getrandbits(0x58)}")[:9] @@ -3445,15 +3532,20 @@ def create_incomplete_filing( filing_dict: dict = copy.deepcopy(ANNUAL_REPORT), filing_type=None, filing_sub_type=None, + is_future_effective=False ): """Create an incomplete filing of a given status.""" filing_dict["filing"]["header"]["name"] = filing_name if filing_dict: filing_dict = copy.deepcopy(filing_dict) - filing = factory_filing(legal_entity=legal_entity, data_dict=filing_dict, filing_sub_type=filing_sub_type) + filing = factory_filing(legal_entity=legal_entity, + data_dict=filing_dict, + filing_sub_type=filing_sub_type, + is_future_effective=is_future_effective) filing.skip_status_listener = True filing._status = filing_status filing._filing_type = filing_type + filing._filing_sub_type = filing_sub_type return filing @@ -3471,3 +3563,40 @@ def create_filing(legal_entity, filing_type, filing_sub_type=None): legal_entity=legal_entity, data_dict=filing_dict, filing_type=filing_type, filing_sub_type=filing_sub_type ) return filing + + +def create_party_role(role=EntityRole.RoleTypes.completing_party, + first_name=None, last_name=None, middle_initial=None): + completing_party_address = Address(city="Test Mailing City", address_type=Address.DELIVERY) + officer = { + "firstName": first_name or "TEST", + "middleInitial": middle_initial or "TU", + "lastName": last_name or "USER", + "partyType": "person", + "organizationName": "" + } + party_role = factory_party_role( + completing_party_address, + None, + officer, + _datetime.utcnow(), + None, + role + ) + return party_role + + +def create_test_user(suffix=""): + return { + "first_name": f"TEST{suffix}", + "last_name": f"USER{suffix}", + "middle_initial": f"TU{suffix}" + } + + +def create_test_user(first_name=None, last_name=None, middle_initial=None): + return { + "first_name": first_name, + "last_name": last_name, + "middle_initial": middle_initial + } diff --git a/legal-api/tests/unit/services/test_digital_credentials.py b/legal-api/tests/unit/services/test_digital_credentials.py index da411e01cd..f462d417bf 100644 --- a/legal-api/tests/unit/services/test_digital_credentials.py +++ b/legal-api/tests/unit/services/test_digital_credentials.py @@ -15,30 +15,130 @@ Test suite to ensure that the Digital Credentials service are working as expected. """ -from unittest.mock import patch -from uuid import uuid4 +from unittest.mock import MagicMock -from legal_api.models import DCDefinition +import pytest +from legal_api.models import DCDefinition, DCIssuedBusinessUserCredential, EntityRole from legal_api.services import digital_credentials -from legal_api.services.digital_credentials import DigitalCredentialsService + from tests.unit import nested_session +from legal_api.services.digital_credentials import DigitalCredentialsHelpers, DigitalCredentialsService +from tests.unit.models import factory_legal_entity, factory_user + +schema_id = "test_schema_id" +cred_def_id = "test_credential_definition_id" -def test_init_app(session, app): # pylint:disable=unused-argument +def test_init_app(app, session): """Assert that the init app register schema and credential definition.""" - # schema_id = '3ENKbWGgUBXXzDHnG11phS:2:business_schema:1.0.0' - # cred_def_id = '3ENKbWGgUBXXzDHnG11phS:3:CL:146949:business_schema' + # schema_id = "3ENKbWGgUBXXzDHnG11phS:2:business_schema:1.0.0" + # cred_def_id = "3ENKbWGgUBXXzDHnG11phS:3:CL:146949:business_schema" with nested_session(session): - stamp = str(uuid4())[:23] - schema_id = f"{stamp}:2:business_schema:1.0.0" - cred_def_id = f"{stamp}:3:CL:146949:business_schema" - with patch.object(DigitalCredentialsService, "_register_schema", return_value=schema_id): - with patch.object(DigitalCredentialsService, "_register_credential_definitions", return_value=cred_def_id): - digital_credentials.init_app(app) - definition = DCDefinition.find_by_credential_type(DCDefinition.CredentialType.business) - assert definition.schema_id == schema_id - assert definition.schema_name == digital_credentials.business_schema["schema_name"] - assert definition.schema_version == digital_credentials.business_schema["schema_version"] - assert definition.credential_definition_id == cred_def_id - assert not definition.is_deleted + DigitalCredentialsService._fetch_schema = MagicMock(return_value=schema_id) + DigitalCredentialsService._fetch_credential_definition = MagicMock(return_value=cred_def_id) + + digital_credentials.init_app(app) + definition = DCDefinition.find_by_credential_type(DCDefinition.CredentialType.business) + assert definition.schema_id == schema_id + assert definition.schema_name == digital_credentials.business_schema_name + assert definition.schema_version == digital_credentials.business_schema_version + assert definition.credential_definition_id == cred_def_id + assert not definition.is_deleted + + +@pytest.mark.parametrize("test_data", [{ + "business": { + "identifier": "FM1234567", + "entity_type": "SP", + "founding_date": "2010-01-01", + "state": "ACTIVE", + }, + "business_extra": { + "legal_name": "Test Business", + "tax_id": "000000000000001", + }, + "party_roles": [{ + "role": "proprietor" + }], + "user": { + "username": "test", + "lastname": "Last", + "firstname": "First", + }, + "user_extra": { + "middlename": "Middle", + }, + "expected": [ + {"name": "credential_id", "value": ""}, + {"name": "identifier", "value": "FM1234567"}, + {"name": "business_name", "value": "Test Business"}, + {"name": "business_type", "value": "BC Sole Proprietorship"}, + {"name": "cra_business_number", "value": "000000000000001"}, + {"name": "registered_on_dateint", "value": "20100101"}, + {"name": "company_status", "value": "ACTIVE"}, + {"name": "family_name", "value": "LAST"}, + {"name": "given_names", "value": "FIRST MIDDLE"}, + {"name": "role", "value": "Proprietor"} + ] +}, { + "business": { + "identifier": "FM1234567" + }, + "business_extra": { + "legal_name": "", + "tax_id": "", + }, + "party_roles": [{ + "role": "" + }], + "user": { + "username": "test" + }, + "user_extra": { + "middlename": "", + }, + "expected": [ + {"name": "credential_id", "value": ""}, + {"name": "identifier", "value": "FM1234567"}, + {"name": "business_name", "value": ""}, + {"name": "business_type", "value": "BC Cooperative Association"}, + {"name": "cra_business_number", "value": ""}, + {"name": "registered_on_dateint", "value": "19700101"}, + {"name": "company_status", "value": "ACTIVE"}, + {"name": "family_name", "value": ""}, + {"name": "given_names", "value": ""}, + {"name": "role", "value": ""} + ] +}]) +def test_data_helper(app, session, test_data): + """Assert that the data helper returns the correct data.""" + # Arrange + credential_type = DCDefinition.CredentialType.business + + user = factory_user(**test_data["user"]) + user.middlename = test_data["user_extra"]["middlename"] + user.save() + + business = factory_legal_entity(**test_data["business"]) + business.legal_name = test_data["business_extra"]["legal_name"] + business.tax_id = test_data["business_extra"]["tax_id"] + business.save() + + for party_role in test_data["party_roles"]: + _party_role = EntityRole(**party_role) + _party_role.legal_entity_id = business.id + _party_role.save() + + issued_business_user_credential = DCIssuedBusinessUserCredential(legal_entity_id=business.id, user_id=user.id) + issued_business_user_credential.save() + + # Act + credential_data = DigitalCredentialsHelpers.get_digital_credential_data(business, user, credential_type) + + # Assert + for item in credential_data: + if item["name"] == "credential_id": + assert item["value"] == f"{issued_business_user_credential.id:08}" + else: + assert item in test_data["expected"] diff --git a/legal-api/tests/unit/services/test_pdf_service.py b/legal-api/tests/unit/services/test_pdf_service.py index 6cfccc7d5a..73588ba058 100644 --- a/legal-api/tests/unit/services/test_pdf_service.py +++ b/legal-api/tests/unit/services/test_pdf_service.py @@ -22,7 +22,7 @@ from legal_api.reports.registrar_meta import RegistrarInfo from legal_api.services import PdfService -from legal_api.services.pdf_service import _write_text +from legal_api.services.pdf_service import RegistrarStampData, _write_text from legal_api.utils.legislation_datetime import LegislationDatetime @@ -31,23 +31,20 @@ def test_stamp(app): # pylint:disable=unused-argument with app.app_context(): pdf_input = _create_pdf_file() incorp_date = LegislationDatetime.now() - registrar_info = RegistrarInfo.get_registrar_info(incorp_date) - registrars_signature = registrar_info["signatureAndText"] pdf_service = PdfService() - registrars_stamp = pdf_service.create_registrars_stamp( - registrars_signature, incorp_date, "CP00000001", "rules.pdf" - ) + registrar_stamp_data = RegistrarStampData(incorp_date, "CP00000001", file_name="rules.pdf") + registrars_stamp = pdf_service.create_registrars_stamp(registrar_stamp_data) certified_copy = pdf_service.stamp_pdf(pdf_input, registrars_stamp, only_first_page=True) - certified_copy_obj = PyPDF2.PdfReader(certified_copy) + certified_copy_obj = PyPDF2.PdfFileReader(certified_copy) - certified_copy_page = certified_copy_obj.pages[0] - text = certified_copy_page.extract_text() + certified_copy_page = certified_copy_obj.getPage(0) + text = certified_copy_page.extractText() assert "Filed on" in text assert "File Name: rules.pdf" in text - certified_copy_page = certified_copy_obj.pages[1] - text = certified_copy_page.extract_text() + certified_copy_page = certified_copy_obj.getPage(1) + text = certified_copy_page.extractText() assert "Filed on" not in text # Uncomment to generate the file: diff --git a/legal-api/tests/unit/services/warnings/business/business_checks/test_firms.py b/legal-api/tests/unit/services/warnings/business/business_checks/test_firms.py index 0f5a2f75cb..4b3a43f7a0 100644 --- a/legal-api/tests/unit/services/warnings/business/business_checks/test_firms.py +++ b/legal-api/tests/unit/services/warnings/business/business_checks/test_firms.py @@ -52,46 +52,16 @@ [ # business office mailing address checks ("SUCCESS", "mailing", None, BusinessWarningReferers.BUSINESS_OFFICE, None, None), - ( - "FAIL_NO_STREET", - "mailing", - "street", - BusinessWarningReferers.BUSINESS_OFFICE, - "NO_BUSINESS_OFFICE_MAILING_ADDRESS_STREET", - "Street is required for business office mailing address.", - ), - ( - "FAIL_NO_CITY", - "mailing", - "city", - BusinessWarningReferers.BUSINESS_OFFICE, - "NO_BUSINESS_OFFICE_MAILING_ADDRESS_CITY", - "City is required for business office mailing address.", - ), - ( - "FAIL_NO_COUNTRY", - "mailing", - "country", - BusinessWarningReferers.BUSINESS_OFFICE, - "NO_BUSINESS_OFFICE_MAILING_ADDRESS_COUNTRY", - "Country is required for business office mailing address.", - ), - ( - "FAIL_NO_POSTAL_CODE", - "mailing", - "postal_code", - BusinessWarningReferers.BUSINESS_OFFICE, - "NO_BUSINESS_OFFICE_MAILING_ADDRESS_POSTAL_CODE", - "Postal code is required for business office mailing address.", - ), - ( - "FAIL_NO_REGION", - "mailing", - "region", - BusinessWarningReferers.BUSINESS_OFFICE, - "NO_BUSINESS_OFFICE_MAILING_ADDRESS_REGION", - "Region is required for business office mailing address.", - ), + ("FAIL_NO_STREET", "mailing", "street", BusinessWarningReferers.BUSINESS_OFFICE, + "NO_BUSINESS_OFFICE_MAILING_ADDRESS_STREET", "Street is required for business office mailing address."), + ("FAIL_NO_CITY", "mailing", "city", BusinessWarningReferers.BUSINESS_OFFICE, + "NO_BUSINESS_OFFICE_MAILING_ADDRESS_CITY", "City is required for business office mailing address."), + ("FAIL_NO_COUNTRY", "mailing", "country", BusinessWarningReferers.BUSINESS_OFFICE, + "NO_BUSINESS_OFFICE_MAILING_ADDRESS_COUNTRY", "Country is required for business office mailing address."), + ("FAIL_NO_POSTAL_CODE", "mailing", "postal_code", BusinessWarningReferers.BUSINESS_OFFICE, + "NO_BUSINESS_OFFICE_MAILING_ADDRESS_POSTAL_CODE", "Postal code is required for business office mailing address."), + ("SUCCESS", "mailing", "region", BusinessWarningReferers.BUSINESS_OFFICE, + None, None), # business office delivery address checks ("SUCCESS", "delivery", None, BusinessWarningReferers.BUSINESS_OFFICE, None, None), ( @@ -136,90 +106,30 @@ ), # business office mailing address checks ("SUCCESS", "mailing", None, BusinessWarningReferers.BUSINESS_PARTY, None, None), - ( - "FAIL_NO_STREET", - "mailing", - "street", - BusinessWarningReferers.BUSINESS_PARTY, - "NO_BUSINESS_PARTY_MAILING_ADDRESS_STREET", - "Street is required for business party mailing address.", - ), - ( - "FAIL_NO_CITY", - "mailing", - "city", - BusinessWarningReferers.BUSINESS_PARTY, - "NO_BUSINESS_PARTY_MAILING_ADDRESS_CITY", - "City is required for business party mailing address.", - ), - ( - "FAIL_NO_COUNTRY", - "mailing", - "country", - BusinessWarningReferers.BUSINESS_PARTY, - "NO_BUSINESS_PARTY_MAILING_ADDRESS_COUNTRY", - "Country is required for business party mailing address.", - ), - ( - "FAIL_NO_POSTAL_CODE", - "mailing", - "postal_code", - BusinessWarningReferers.BUSINESS_PARTY, - "NO_BUSINESS_PARTY_MAILING_ADDRESS_POSTAL_CODE", - "Postal code is required for business party mailing address.", - ), - ( - "FAIL_NO_REGION", - "mailing", - "region", - BusinessWarningReferers.BUSINESS_PARTY, - "NO_BUSINESS_PARTY_MAILING_ADDRESS_REGION", - "Region is required for business party mailing address.", - ), + ("FAIL_NO_STREET", "mailing", "street", BusinessWarningReferers.BUSINESS_PARTY, + "NO_BUSINESS_PARTY_MAILING_ADDRESS_STREET", "Street is required for business party mailing address."), + ("FAIL_NO_CITY", "mailing", "city", BusinessWarningReferers.BUSINESS_PARTY, + "NO_BUSINESS_PARTY_MAILING_ADDRESS_CITY", "City is required for business party mailing address."), + ("FAIL_NO_COUNTRY", "mailing", "country", BusinessWarningReferers.BUSINESS_PARTY, + "NO_BUSINESS_PARTY_MAILING_ADDRESS_COUNTRY", "Country is required for business party mailing address."), + ("FAIL_NO_POSTAL_CODE", "mailing", "postal_code", BusinessWarningReferers.BUSINESS_PARTY, + "NO_BUSINESS_PARTY_MAILING_ADDRESS_POSTAL_CODE", "Postal code is required for business party mailing address."), + ("SUCCESS", "mailing", "region", BusinessWarningReferers.BUSINESS_PARTY, + None, None), + # completing party mailing address checks ("SUCCESS", "mailing", None, BusinessWarningReferers.COMPLETING_PARTY, None, None), - ( - "FAIL_NO_STREET", - "mailing", - "street", - BusinessWarningReferers.COMPLETING_PARTY, - "NO_COMPLETING_PARTY_MAILING_ADDRESS_STREET", - "Street is required for completing party mailing address.", - ), - ( - "FAIL_NO_CITY", - "mailing", - "city", - BusinessWarningReferers.COMPLETING_PARTY, - "NO_COMPLETING_PARTY_MAILING_ADDRESS_CITY", - "City is required for completing party mailing address.", - ), - ( - "FAIL_NO_COUNTRY", - "mailing", - "country", - BusinessWarningReferers.COMPLETING_PARTY, - "NO_COMPLETING_PARTY_MAILING_ADDRESS_COUNTRY", - "Country is required for completing party mailing address.", - ), - ( - "FAIL_NO_POSTAL_CODE", - "mailing", - "postal_code", - BusinessWarningReferers.COMPLETING_PARTY, - "NO_COMPLETING_PARTY_MAILING_ADDRESS_POSTAL_CODE", - "Postal code is required for completing party mailing address.", - ), - ( - "FAIL_NO_REGION", - "mailing", - "region", - BusinessWarningReferers.COMPLETING_PARTY, - "NO_COMPLETING_PARTY_MAILING_ADDRESS_REGION", - "Region is required for completing party mailing address.", - ), - ], -) + ("FAIL_NO_STREET", "mailing", "street", BusinessWarningReferers.COMPLETING_PARTY, + "NO_COMPLETING_PARTY_MAILING_ADDRESS_STREET", "Street is required for completing party mailing address."), + ("FAIL_NO_CITY", "mailing", "city", BusinessWarningReferers.COMPLETING_PARTY, + "NO_COMPLETING_PARTY_MAILING_ADDRESS_CITY", "City is required for completing party mailing address."), + ("FAIL_NO_COUNTRY", "mailing", "country", BusinessWarningReferers.COMPLETING_PARTY, + "NO_COMPLETING_PARTY_MAILING_ADDRESS_COUNTRY", "Country is required for completing party mailing address."), + ("FAIL_NO_POSTAL_CODE", "mailing", "postal_code", BusinessWarningReferers.COMPLETING_PARTY, + "NO_COMPLETING_PARTY_MAILING_ADDRESS_POSTAL_CODE", "Postal code is required for completing party mailing address."), + ("SUCCESS", "mailing", "region", BusinessWarningReferers.COMPLETING_PARTY, + None, None), + ]) def test_check_address(session, test_name, address_type, null_addr_field_name, referer, expected_code, expected_msg): """Assert that business address checks functions properly.""" @@ -868,8 +778,8 @@ def test_check_office(session, test_name, legal_type, identifier, expected_code, None, ), # # GP tests - # ('SUCCESS_PARTY_MA_MISSING_STREET', 'GP', 'FM0000001', 3, 0, [None, None, datetime.utcnow()], [], - # ['registration'], [True], None, None), + # ("SUCCESS_PARTY_MA_MISSING_STREET", "GP", "FM0000001", 3, 0, [None, None, datetime.utcnow()], [], + # ["registration"], [True], None, None), ], ) def test_check_parties_cessation_date( diff --git a/legal-api/wsgi.py b/legal-api/wsgi.py index da70e2b88f..f1581621f7 100755 --- a/legal-api/wsgi.py +++ b/legal-api/wsgi.py @@ -18,7 +18,7 @@ from legal_api import create_app, db from flask_migrate import Migrate -app = create_app() # pylint: disable=invalid-name +app = create_app() # pylint: disable=invalid-name migrate = Migrate(app, db) if __name__ == "__main__": diff --git a/queue_services/entity-bn/Makefile b/queue_services/entity-bn/Makefile index 4a3712a7ea..66513324d3 100644 --- a/queue_services/entity-bn/Makefile +++ b/queue_services/entity-bn/Makefile @@ -39,7 +39,7 @@ clean-test: ## clean test files build-req: clean ## Upgrade requirements test -f venv/bin/activate || python3 -m venv $(CURRENT_ABS_DIR)/venv ;\ . venv/bin/activate ;\ - pip install pip==20.1.1 ;\ + pip install --upgrade pip ;\ pip install -Ur requirements/prod.txt ;\ pip freeze | sort > requirements.txt ;\ cat requirements/bcregistry-libraries.txt >> requirements.txt ;\ @@ -48,7 +48,7 @@ build-req: clean ## Upgrade requirements install: clean ## Install python virtrual environment test -f venv/bin/activate || python3 -m venv $(CURRENT_ABS_DIR)/venv ;\ . venv/bin/activate ;\ - pip install pip==20.1.1 ;\ + pip install --upgrade pip ;\ pip install -Ur requirements.txt install-dev: ## Install local application diff --git a/queue_services/entity-bn/src/entity_bn/bn_processors/registration.py b/queue_services/entity-bn/src/entity_bn/bn_processors/registration.py index 8dbed2864a..6320675c72 100644 --- a/queue_services/entity-bn/src/entity_bn/bn_processors/registration.py +++ b/queue_services/entity-bn/src/entity_bn/bn_processors/registration.py @@ -20,6 +20,7 @@ import requests from flask import current_app, request from legal_api.models import ColinEntity, EntityRole, LegalEntity, RequestTracker +from legal_api.services.bootstrap import AccountService from legal_api.utils.datetime import datetime from legal_api.utils.legislation_datetime import LegislationDatetime from simple_cloudevent import SimpleCloudEvent @@ -148,7 +149,8 @@ def process( ) mail_topic = current_app.config.get("ENTITY_MAILER_TOPIC", "mailer") - queue.publish(topic=mail_topic, payload=queue.to_queue_message(cloud_event)) + queue.publish(topic=mail_topic, + payload=queue.to_queue_message(cloud_event)) except ( Exception ) as err: # pylint: disable=broad-except, unused-variable # noqa F841; @@ -262,7 +264,8 @@ def _get_bn( legal_entity.identifier, transaction_id ) if status_code == HTTPStatus.OK: - program_account_ref_no = str(response["program_account_ref_no"]).zfill(4) + program_account_ref_no = str( + response["program_account_ref_no"]).zfill(4) bn15 = f"{response['business_no']}{response['business_program_id']}{program_account_ref_no}" alternate_name = legal_entity._alternate_names.first() alternate_name.bn15 = bn15 @@ -278,8 +281,12 @@ def _get_program_account(identifier, transaction_id): try: # Note: Dev environment don’t have BNI link. So this will never work in Dev environment. # Use Test environment for testing. + token = AccountService.get_bearer_token() url = f'{current_app.config["COLIN_API"]}/programAccount/{identifier}/{transaction_id}' - response = requests.get(url) + response = requests.get(url, + headers={**AccountService.CONTENT_TYPE_JSON, + "Authorization": AccountService.BEARER + token}, + timeout=AccountService.timeout) return response.status_code, response.json() except requests.exceptions.RequestException as err: structured_log(request, "ERROR", str(err)) diff --git a/queue_services/entity-bn/src/entity_bn/config.py b/queue_services/entity-bn/src/entity_bn/config.py index 9872c97fda..26c6df9734 100644 --- a/queue_services/entity-bn/src/entity_bn/config.py +++ b/queue_services/entity-bn/src/entity_bn/config.py @@ -40,6 +40,12 @@ class Config: # pylint: disable=too-few-public-methods SENTRY_DSN = os.getenv("SENTRY_DSN", None) + # service accounts + ACCOUNT_SVC_AUTH_URL = os.getenv("ACCOUNT_SVC_AUTH_URL") + ACCOUNT_SVC_CLIENT_ID = os.getenv("ACCOUNT_SVC_CLIENT_ID") + ACCOUNT_SVC_CLIENT_SECRET = os.getenv("ACCOUNT_SVC_CLIENT_SECRET") + ACCOUNT_SVC_TIMEOUT = os.getenv("ACCOUNT_SVC_TIMEOUT") + SQLALCHEMY_TRACK_MODIFICATIONS = False # POSTGRESQL DB_USER = os.getenv("DATABASE_USERNAME", "") @@ -57,7 +63,8 @@ class Config: # pylint: disable=too-few-public-methods ) # legislative timezone for future effective dating - LEGISLATIVE_TIMEZONE = os.getenv("LEGISLATIVE_TIMEZONE", "America/Vancouver") + LEGISLATIVE_TIMEZONE = os.getenv( + "LEGISLATIVE_TIMEZONE", "America/Vancouver") TEMPLATE_PATH = os.getenv("TEMPLATE_PATH", None) # API Endpoints diff --git a/queue_services/entity-digital-credentials/.env.sample b/queue_services/entity-digital-credentials/.env.sample new file mode 100644 index 0000000000..030dbe3a45 --- /dev/null +++ b/queue_services/entity-digital-credentials/.env.sample @@ -0,0 +1,39 @@ + +# Flask +FLASK_ENV= + + +### SQL Alchemy +ENTITY_DATABASE_USERNAME= +ENTITY_DATABASE_PASSWORD= +ENTITY_DATABASE_NAME= +ENTITY_DATABASE_HOST= +ENTITY_DATABASE_PORT= + +DATABASE_TEST_USERNAME= +DATABASE_TEST_PASSWORD= +DATABASE_TEST_NAME= +DATABASE_TEST_HOST= +DATABASE_TEST_PORT= + + +## ## NATS - STAN +NATS_SERVERS= +NATS_CLIENT_NAME= +NATS_CLUSTER_ID= +NATS_EMAILER_SUBJECT= +NATS_ENTITY_EVENT_SUBJECT= +#NATS_QUEUE= +NATS_QUEUE= +STAN_CLUSTER_NAME= + +# ## NATS - STAN - DEV +#NATS_SERVERS= +#NATS_CLIENT_NAME= +#NATS_CLUSTER_ID= +#NATS_EMAILER_SUBJECT= +#NATS_ENTITY_EVENT_SUBJECT= +#NATS_QUEUE= + + +#DEPLOYMENT_ENV= diff --git a/queue_services/entity-digital-credentials/.envrc b/queue_services/entity-digital-credentials/.envrc new file mode 100644 index 0000000000..64fff9a69f --- /dev/null +++ b/queue_services/entity-digital-credentials/.envrc @@ -0,0 +1,6 @@ +while read -r line; do + echo $line + [[ "$line" =~ ^#.*$ ]] && continue + export $line +done < .env +source venv/bin/activate diff --git a/queue_services/entity-digital-credentials/Dockerfile b/queue_services/entity-digital-credentials/Dockerfile new file mode 100644 index 0000000000..f101247d63 --- /dev/null +++ b/queue_services/entity-digital-credentials/Dockerfile @@ -0,0 +1,37 @@ +# platform=linux/amd64 +FROM python:3.8.6-buster + +ARG VCS_REF="missing" +ARG BUILD_DATE="missing" + +ENV VCS_REF=${VCS_REF} +ENV BUILD_DATE=${BUILD_DATE} + +LABEL org.label-schema.vcs-ref=${VCS_REF} \ + org.label-schema.build-date=${BUILD_DATE} + +USER root + +# Create working directory +RUN mkdir /opt/app-root && chmod 755 /opt/app-root +WORKDIR /opt/app-root + +# Install the requirements +COPY ./requirements.txt . + +#RUN pip install --upgrade pip +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN pip install . + +USER 1001 + +# Set Python path +ENV PYTHONPATH=/opt/app-root/src + +EXPOSE 8080 + +CMD [ "python", "/opt/app-root/digital_credentials_service.py" ] diff --git a/queue_services/entity-digital-credentials/LICENSE b/queue_services/entity-digital-credentials/LICENSE new file mode 100644 index 0000000000..efe5d4e2d1 --- /dev/null +++ b/queue_services/entity-digital-credentials/LICENSE @@ -0,0 +1,13 @@ +Copyright © 2023 Province of British Columbia + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/queue_services/entity-digital-credentials/Makefile b/queue_services/entity-digital-credentials/Makefile new file mode 100644 index 0000000000..ef971c9f83 --- /dev/null +++ b/queue_services/entity-digital-credentials/Makefile @@ -0,0 +1,144 @@ +.PHONY: license +.PHONY: setup +.PHONY: ci cd +.PHONY: run + +MKFILE_PATH:=$(abspath $(lastword $(MAKEFILE_LIST))) +CURRENT_ABS_DIR:=$(patsubst %/,%,$(dir $(MKFILE_PATH))) + +PROJECT_NAME:=entity_digital_credentials +DOCKER_NAME:=entity-digital-credentials + +################################################################################# +# COMMANDS -- Setup # +################################################################################# +setup: install install-dev ## Setup the project + +clean: clean-build clean-pyc clean-test ## Clean the project + rm -rf venv/ + +clean-build: ## Clean build files + rm -fr build/ + rm -fr dist/ + rm -fr .eggs/ + find . -name '*.egg-info' -exec rm -fr {} + + find . -name '*.egg' -exec rm -fr {} + + +clean-pyc: ## Clean cache files + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + find . -name '__pycache__' -exec rm -fr {} + + +clean-test: ## clean test files + find . -name '.pytest_cache' -exec rm -fr {} + + rm -fr .tox/ + rm -f .coverage + rm -fr htmlcov/ + +build-req: clean ## Upgrade requirements + test -f venv/bin/activate || python3.8 -m venv $(CURRENT_ABS_DIR)/venv ;\ + . venv/bin/activate ;\ + pip install upgrade pip ;\ + pip install -Ur requirements/prod.txt ;\ + pip freeze | sort > requirements.txt ;\ + cat requirements/bcregistry-libraries.txt >> requirements.txt ;\ + pip install -Ur requirements/bcregistry-libraries.txt + +install: clean ## Install python virtrual environment + test -f venv/bin/activate || python3.8 -m venv $(CURRENT_ABS_DIR)/venv ;\ + . venv/bin/activate ;\ + pip install upgrade pip ;\ + pip install -Ur requirements.txt + +install-dev: ## Install application for local development + . venv/bin/activate ; \ + pip install -Ur requirements/dev.txt; \ + pip install -e . + +################################################################################# +# COMMANDS - CI # +################################################################################# +ci: pylint flake8 test ## CI flow + +pylint: ## Linting with pylint + . venv/bin/activate && pylint --rcfile=setup.cfg src/$(PROJECT_NAME) + +flake8: ## Linting with flake8 + . venv/bin/activate && flake8 src/$(PROJECT_NAME) tests + +lint: pylint flake8 ## run all lint type scripts + +test: ## Unit testing + . venv/bin/activate && pytest + +mac-cov: test ## Run the coverage report and display in a browser window (mac) + @open -a "Google Chrome" htmlcov/index.html + +################################################################################# +# COMMANDS - CD +# expects the terminal to be openshift login +# expects export OPENSHIFT_DOCKER_REGISTRY="" +# expects export OPENSHIFT_SA_NAME="$(oc whoami)" +# expects export OPENSHIFT_SA_TOKEN="$(oc whoami -t)" +# expects export OPENSHIFT_REPOSITORY="" +# expects export TAG_NAME="dev/test/prod" +# expects export OPS_REPOSITORY="" # +################################################################################# +cd: ## CD flow +ifeq ($(TAG_NAME), test) +cd: update-env + oc -n "$(OPENSHIFT_REPOSITORY)-tools" tag $(DOCKER_NAME):dev $(DOCKER_NAME):$(TAG_NAME) +else ifeq ($(TAG_NAME), prod) +cd: update-env + oc -n "$(OPENSHIFT_REPOSITORY)-tools" tag $(DOCKER_NAME):$(TAG_NAME) $(DOCKER_NAME):$(TAG_NAME)-$(shell date +%F) + oc -n "$(OPENSHIFT_REPOSITORY)-tools" tag $(DOCKER_NAME):test $(DOCKER_NAME):$(TAG_NAME) +else +TAG_NAME=dev +cd: build update-env tag +endif + +build: ## Build the docker container + docker build . -t $(DOCKER_NAME) \ + --build-arg VCS_REF=$(shell git rev-parse --short HEAD) \ + --build-arg BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") \ + +build-nc: ## Build the docker container without caching + docker build --no-cache -t $(DOCKER_NAME) . + +REGISTRY_IMAGE=$(OPENSHIFT_DOCKER_REGISTRY)/$(OPENSHIFT_REPOSITORY)-tools/$(DOCKER_NAME) +push: #build ## Push the docker container to the registry & tag latest + @echo "$(OPENSHIFT_SA_TOKEN)" | docker login $(OPENSHIFT_DOCKER_REGISTRY) -u $(OPENSHIFT_SA_NAME) --password-stdin ;\ + docker tag $(DOCKER_NAME) $(REGISTRY_IMAGE):latest ;\ + docker push $(REGISTRY_IMAGE):latest + +VAULTS=`cat devops/vaults.json` +update-env: ## Update env from 1pass + oc -n "$(OPS_REPOSITORY)-$(TAG_NAME)" exec "dc/vault-service-$(TAG_NAME)" -- ./scripts/1pass.sh \ + -m "secret" \ + -e "$(TAG_NAME)" \ + -a "$(DOCKER_NAME)-$(TAG_NAME)" \ + -n "$(OPENSHIFT_REPOSITORY)-$(TAG_NAME)" \ + -v "$(VAULTS)" \ + -r "true" \ + -f "false" + +tag: push ## tag image + oc -n "$(OPENSHIFT_REPOSITORY)-tools" tag $(DOCKER_NAME):latest $(DOCKER_NAME):$(TAG_NAME) + +################################################################################# +# COMMANDS - Local # +################################################################################# + +run: ## Run the project in local + . venv/bin/activate && python digital_credentials_service.py + +################################################################################# +# Self Documenting Commands # +################################################################################# +.PHONY: help + +.DEFAULT_GOAL := help + +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/queue_services/entity-digital-credentials/README.md b/queue_services/entity-digital-credentials/README.md new file mode 100644 index 0000000000..e2d0b799de --- /dev/null +++ b/queue_services/entity-digital-credentials/README.md @@ -0,0 +1,46 @@ + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![codecov](https://codecov.io/gh/bcgov/lear/branch/master/graph/badge.svg?flag=entitydigitalcredentials)](https://codecov.io/gh/bcgov/lear/tree/master/queue_services/entity-digital-credentials) + +# Application Name + +BC Registries Entity Digital Credentials Service + +## Technology Stack Used +* NATS-streaming +* Python +* Postgres - SQLAlchemy, psycopg2-binary & alembic + +## Project Status + +## Documentation + +## Security + +## Getting Help or Reporting an Issue + +To report bugs/issues/feature requests, please file an [issue](https://github.com/bcgov/entity/issues). + +## How to Contribute + +If you would like to contribute, please see our [CONTRIBUTING](./CONTRIBUTING.md) guidelines. + +Please note that this project is released with a [Contributor Code of Conduct](./CODE_OF_CONDUCT.md). +By participating in this project you agree to abide by its terms. + +## License + + Copyright 2023 Province of British Columbia + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/queue_services/entity-digital-credentials/devops/vaults.json b/queue_services/entity-digital-credentials/devops/vaults.json new file mode 100644 index 0000000000..8dfd8113f6 --- /dev/null +++ b/queue_services/entity-digital-credentials/devops/vaults.json @@ -0,0 +1,33 @@ +[ + { + "vault": "entity", + "application": [ + "launchdarkly" + ] + }, + { + "vault": "database", + "application": [ + "entity-db" + ] + }, + { + "vault": "nats", + "application": [ + "base", + "entity-digital-credentials" + ] + }, + { + "vault": "bbdc", + "application": [ + "aca-py" + ] + }, + { + "vault": "sentry", + "application": [ + "entity" + ] + } +] diff --git a/queue_services/entity-digital-credentials/digital_credentials_service.py b/queue_services/entity-digital-credentials/digital_credentials_service.py new file mode 100644 index 0000000000..cf6530af16 --- /dev/null +++ b/queue_services/entity-digital-credentials/digital_credentials_service.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""s2i based launch script to run the service.""" +import asyncio + +from entity_digital_credentials.worker import APP_CONFIG, cb_subscription_handler, qsm + + +if __name__ == '__main__': + + event_loop = asyncio.get_event_loop() + event_loop.run_until_complete(qsm.run(loop=event_loop, + config=APP_CONFIG, + callback=cb_subscription_handler)) + try: + event_loop.run_forever() + finally: + event_loop.close() diff --git a/queue_services/entity-digital-credentials/k8s/Readme.md b/queue_services/entity-digital-credentials/k8s/Readme.md new file mode 100644 index 0000000000..40227bf8d2 --- /dev/null +++ b/queue_services/entity-digital-credentials/k8s/Readme.md @@ -0,0 +1,7 @@ +# buildconfig +oc process -f openshift/templates/bc.yaml -o yaml | oc apply -f - -n cc892f-tools +# deploymentconfig, service +oc process -f openshift/templates/dc.yaml -o yaml | oc apply -f - -n cc892f-dev +oc process -f openshift/templates/dc.yaml -p TAG=test -o yaml | oc apply -f - -n cc892f-test +oc process -f openshift/templates/dc.yaml -p TAG=prod -o yaml | oc apply -f - -n cc892f-prod + diff --git a/queue_services/entity-digital-credentials/k8s/templates/dc.yaml b/queue_services/entity-digital-credentials/k8s/templates/dc.yaml new file mode 100644 index 0000000000..adf95338f4 --- /dev/null +++ b/queue_services/entity-digital-credentials/k8s/templates/dc.yaml @@ -0,0 +1,175 @@ +--- +kind: Template +apiVersion: v1 +metadata: + name: ${NAME}-${TAG}-deployment-template + annotations: + description: + Deployment template for an API application and connect to database. + tags: Flask + iconClass: icon-python +objects: + - kind: Service + apiVersion: v1 + metadata: + name: ${NAME}-${TAG} + labels: + name: ${NAME} + environment: ${TAG} + role: ${ROLE} + spec: + ports: + - name: ${NAME}-${TAG}-tcp + port: 8080 + targetPort: 8080 + selector: + name: ${NAME} + environment: ${TAG} + + - kind: DeploymentConfig + apiVersion: v1 + metadata: + name: ${NAME}-${TAG} + labels: + name: ${NAME} + environment: ${TAG} + role: ${ROLE} + annotations: + description: Defines how to deploy the application server + spec: + strategy: + rollingParams: + intervalSeconds: 1 + maxSurge: 25% + maxUnavailable: 25% + timeoutSeconds: 600 + updatePeriodSeconds: 1 + type: Rolling + triggers: + - type: ImageChange + imageChangeParams: + automatic: true + containerNames: + - ${NAME}-${TAG} + from: + kind: ImageStreamTag + namespace: ${NAMESPACE}-${IMAGE_NAMESPACE} + name: ${NAME}:${TAG} + replicas: 1 + selector: + name: ${NAME} + environment: ${TAG} + template: + metadata: + name: ${NAME}-${TAG} + labels: + name: ${NAME} + environment: ${TAG} + role: ${ROLE} + spec: + containers: + - name: ${NAME}-${TAG} + image: ${IMAGE_REGISTRY}/${NAMESPACE}-${IMAGE_NAMESPACE}/${NAME}:${TAG} + ports: + - containerPort: 8080 + protocol: TCP + readinessProbe: + initialDelaySeconds: 3 + timeoutSeconds: 30 + httpGet: + path: /readyz + port: 7070 + livenessProbe: + initialDelaySeconds: 120 + timeoutSeconds: 30 + httpGet: + path: /healthz + port: 7070 + + - kind: HorizontalPodAutoscaler + apiVersion: autoscaling/v1 + metadata: + name: ${NAME}-${TAG} + labels: + name: ${NAME} + environment: ${TAG} + role: ${ROLE} + spec: + scaleTargetRef: + kind: DeploymentConfig + name: ${NAME}-${TAG} + minReplicas: ${{MIN_REPLICAS}} + maxReplicas: ${{MAX_REPLICAS}} + +parameters: + - name: NAME + displayName: Name + description: The name assigned to all of the OpenShift resources associated to the server instance. + required: true + value: entity-digital-credentials + + - name: TAG + displayName: Environment TAG name + description: The TAG name for this environment, e.g., dev, test, prod + value: dev + required: true + + - name: ROLE + displayName: Role + description: Role + required: true + value: queue + + - name: NAMESPACE + displayName: Namespace Name + description: The base namespace name for the project. + required: true + value: cc892f + + - name: IMAGE_NAMESPACE + displayName: Image Namespace + required: true + description: The namespace of the OpenShift project containing the imagestream for the application. + value: tools + + - name: IMAGE_REGISTRY + displayName: Image Registry + required: true + description: The image registry of the OpenShift project. + value: image-registry.openshift-image-registry.svc:5000 + + - name: MIN_REPLICAS + displayName: Minimum Replicas + description: The minimum number of pods to have running. + required: true + value: "1" + + - name: MAX_REPLICAS + displayName: Maximum Replicas + description: The maximum number of pods to have running. + required: true + value: "1" + + - name: CPU_REQUEST + displayName: Resources CPU Request + description: The resources CPU request (in cores) for this build. + required: true + value: 10m + + - name: CPU_LIMIT + displayName: Resources CPU Limit + description: The resources CPU limit (in cores) for this build. + required: true + value: 500m + + - name: MEMORY_REQUEST + displayName: Resources Memory Request + description: The resources Memory request (in Mi, Gi, etc) for this build. + required: true + value: 10Mi + + - name: MEMORY_LIMIT + displayName: Resources Memory Limit + description: The resources Memory limit (in Mi, Gi, etc) for this build. + required: true + value: 1Gi diff --git a/queue_services/entity-digital-credentials/q_cli.py b/queue_services/entity-digital-credentials/q_cli.py new file mode 100644 index 0000000000..c893b85cda --- /dev/null +++ b/queue_services/entity-digital-credentials/q_cli.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Service for listening and handling Queue Messages. + +This service registers interest in listening to a Queue and processing received messages. +""" +import asyncio +import functools +import getopt +import json +import os +import random +import signal +import sys +import time +import uuid + +from datetime import datetime, timezone + +from nats.aio.client import Client as NATS # noqa N814; by convention the name is NATS +from stan.aio.client import Client as STAN # noqa N814; by convention the name is STAN + +from entity_queue_common.service_utils import error_cb, logger, signal_handler + + +async def run(loop, identifier, filing_id, filing_type): # pylint: disable=too-many-locals + """Run the main application loop for the service. + + This runs the main top level service functions for working with the Queue. + """ + # NATS client connections + nc = NATS() + sc = STAN() + + async def close(): + """Close the stream and nats connections.""" + await sc.close() + await nc.close() + + # Connection and Queue configuration. + def nats_connection_options(): + return { + 'servers': os.getenv('NATS_SERVERS', 'nats://127.0.0.1:4222').split(','), + 'io_loop': loop, + 'error_cb': error_cb, + 'name': os.getenv('NATS_CLIENT_NAME', 'entity.filing.tester') + } + + def stan_connection_options(): + return { + 'cluster_id': os.getenv('NATS_CLUSTER_ID', 'test-cluster'), + 'client_id': str(random.SystemRandom().getrandbits(0x58)), + 'nats': nc + } + + def subscription_options(): + return { + 'subject': os.getenv('NATS_ENTITY_EVENT_SUBJECT', 'error'), + 'queue': os.getenv('NATS_QUEUE', 'error'), + 'durable_name': os.getenv('NATS_QUEUE', 'error') + '_durable' + } + + try: + # Connect to the NATS server, and then use that for the streaming connection. + await nc.connect(**nats_connection_options()) + await sc.connect(**stan_connection_options()) + + # register the signal handler + for sig in ('SIGINT', 'SIGTERM'): + loop.add_signal_handler(getattr(signal, sig), + functools.partial(signal_handler, sig_loop=loop, sig_nc=nc, task=close) + ) + + payload = { + 'specversion': '1.x-wip', + 'source': f'/businesses/{identifier}', + 'id': str(uuid.uuid4()), + 'time': datetime.utcfromtimestamp(time.time()).replace(tzinfo=timezone.utc).isoformat(), + 'datacontenttype': 'application/json', + 'identifier': identifier, + 'data': { + 'filing': { + 'business': {'identifier': identifier} + } + } + } + + if filing_type == 'admin.revoke': + payload['type'] = 'bc.registry.admin.revoke' + else: + payload['type'] = f'bc.registry.business.{filing_type}' + + if filing_id is not None: + payload['data']['filing']['header'] = {'filingId': filing_id} + + await sc.publish(subject=subscription_options().get('subject'), + payload=json.dumps(payload).encode('utf-8')) + + except Exception as e: # pylint: disable=broad-except + logger.error(e) + + +if __name__ == '__main__': + try: + opts, args = getopt.getopt(sys.argv[1:], 'i:f:t:', ['identifier=', 'filing_id=', 'filing_type=']) + except getopt.GetoptError: + print('q_cli.py -i -f -t ') + sys.exit(2) + filing_id = None + for opt, arg in opts: + if opt == '-h': + print('q_cli.py -i -f -t ') + sys.exit() + elif opt in ('-i', '--identifier'): + identifier = arg + elif opt in ('-f', '--filing-id'): + filing_id = arg + elif opt in ('-t', '--filing-type'): + filing_type = arg + + print('publish:', identifier, filing_id, filing_type) + event_loop = asyncio.get_event_loop() + event_loop.run_until_complete(run(event_loop, identifier, filing_id, filing_type)) diff --git a/queue_services/entity-digital-credentials/requirements.txt b/queue_services/entity-digital-credentials/requirements.txt new file mode 100644 index 0000000000..00c53fc261 --- /dev/null +++ b/queue_services/entity-digital-credentials/requirements.txt @@ -0,0 +1,24 @@ +aiohttp +asyncio-nats-streaming +asyncio-nats-client==0.11.4 +attrs==23.1.0 +blinker==1.4 +certifi==2020.12.5 +click==8.1.3 +dpath==2.0.1 +Flask==1.1.2 +Jinja2==2.11.3 +itsdangerous==1.1.0 +jsonschema==4.19.0 +MarkupSafe==1.1.1 +protobuf==3.15.8 +pyrsistent==0.17.3 +python-dotenv==0.17.1 +requests==2.25.1 +sentry-sdk==1.20.0 +six==1.15.0 +urllib3==1.26.11 +Werkzeug==1.0.1 +git+https://github.com/bcgov/business-schemas.git@2.18.15#egg=registry_schemas +git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common +git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api \ No newline at end of file diff --git a/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt b/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt new file mode 100644 index 0000000000..f2a149d754 --- /dev/null +++ b/queue_services/entity-digital-credentials/requirements/bcregistry-libraries.txt @@ -0,0 +1,3 @@ +git+https://github.com/bcgov/business-schemas.git@2.18.15#egg=registry_schemas +git+https://github.com/bcgov/lear.git#egg=legal_api&subdirectory=legal-api +git+https://github.com/bcgov/lear.git#egg=entity_queue_common&subdirectory=queue_services/common diff --git a/queue_services/entity-digital-credentials/requirements/dev.txt b/queue_services/entity-digital-credentials/requirements/dev.txt new file mode 100644 index 0000000000..56dd314e08 --- /dev/null +++ b/queue_services/entity-digital-credentials/requirements/dev.txt @@ -0,0 +1,31 @@ +# Everything the developer needs outside of the production requirements + +# Testing +dpath +freezegun +pyhamcrest +pytest +pytest-aiohttp +pytest-asyncio +pytest-mock +requests +requests-mock + +# Lint and code style +pydocstyle<4 +flake8==5.0.4 +flake8-blind-except +flake8-debugger +flake8-docstrings +flake8-isort==5.0.3 +flake8-quotes +pep8-naming +autopep8 +coverage +pylint +pylint-flask +isort<5,>=4.2.5 +pytest-cov + +# docker +lovely-pytest-docker diff --git a/queue_services/entity-digital-credentials/requirements/prod.txt b/queue_services/entity-digital-credentials/requirements/prod.txt new file mode 100644 index 0000000000..9ae2a03618 --- /dev/null +++ b/queue_services/entity-digital-credentials/requirements/prod.txt @@ -0,0 +1,16 @@ +sqlalchemy < 1.4.0 +Flask +Flask-Babel +Flask-Migrate +Flask-Moment +Flask-Script +Flask-SQLAlchemy +asyncio-nats-client +asyncio-nats-streaming +attrs==23.1.0 +jinja2 +python-dotenv<0.16.0 +psycopg2-binary +requests +sentry-sdk[flask] +Werkzeug<1.0.2 diff --git a/queue_services/entity-digital-credentials/setup.cfg b/queue_services/entity-digital-credentials/setup.cfg new file mode 100644 index 0000000000..4a2e06b1da --- /dev/null +++ b/queue_services/entity-digital-credentials/setup.cfg @@ -0,0 +1,120 @@ +[metadata] +name = entity_digital_credentials +url = https://github.com/bcgov/lear/queue-services/entity_digital_credentials +classifiers = + Development Status :: Beta + Intended Audience :: Developers / QA + Topic :: Legal Entities + License :: OSI Approved :: Apache Software License + Natural Language :: English + Programming Language :: Python :: 3.7 +license = Apache Software License Version 2.0 +description = A short description of the project +long_description = file: README.md +keywords = + +[options] +zip_safe = True +python_requires = >=3.6 +include_package_data = True +packages = find: + +[options.package_data] +entity_digital_credentials = + +[wheel] +universal = 1 + +[bdist_wheel] +universal = 1 + +[aliases] +test = pytest + +[flake8] +exclude = .git,*migrations* +max-line-length = 120 +docstring-min-length=10 +per-file-ignores = + */__init__.py:F401 + +[pycodestyle] +max_line_length = 120 +ignore = E501 +docstring-min-length=10 +notes=FIXME,XXX # TODO is ignored +match_dir = src/entity_digital_credentials +ignored-modules=flask_sqlalchemy + sqlalchemy +per-file-ignores = + */__init__.py:F401 +good-names= + b, + d, + i, + e, + f, + k, + u, + v, + ar, + cb, #common shorthand for callback + nc, + rv, + sc, + event_loop, + logger, + loop, + +[pylint] +ignore=migrations,test +max-line-length=120 +notes=FIXME,XXX,TODO +ignored-modules=flask_sqlalchemy,sqlalchemy,SQLAlchemy,alembic,scoped_session +ignored-classes=scoped_session +disable=C0301,W0511,R0801,R0902 + +[isort] +line_length = 120 +indent = 4 +multi_line_output = 3 +lines_after_imports = 2 +include_trailing_comma = True + +[tool:pytest] +minversion = 2.0 +testpaths = tests +addopts = --verbose + --strict + -p no:warnings + --cov=src --cov-report html:htmlcov --cov-report xml:coverage.xml +python_files = tests/*/test*.py +norecursedirs = .git .tox venv* requirements* build +log_cli = true +log_cli_level = 1 +filterwarnings = + ignore::UserWarning +markers = + slow + serial + +[coverage:run] +branch = True +source = + src/entity_digital_credentials +omit = + src/entity_digital_credentials/wsgi.py + src/entity_digital_credentials/gunicorn_config.py + +[report:run] +exclude_lines = + pragma: no cover + from + import + def __repr__ + if self.debug: + if settings.DEBUG + raise AssertionError + raise NotImplementedError + if 0: + if __name__ == .__main__.: diff --git a/queue_services/entity-digital-credentials/setup.py b/queue_services/entity-digital-credentials/setup.py new file mode 100644 index 0000000000..d8c8b2cec5 --- /dev/null +++ b/queue_services/entity-digital-credentials/setup.py @@ -0,0 +1,70 @@ +# Copyright © 2023 Province of British Columbia. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Installer and setup for this module.""" +import ast +import re +from glob import glob +from os.path import basename, splitext + +from setuptools import find_packages, setup + + +_version_re = re.compile(r'__version__\s+=\s+(.*)') # pylint: disable=invalid-name + +with open('src/entity_digital_credentials/version.py', 'rb') as f: + version = str(ast.literal_eval(_version_re.search( # pylint: disable=invalid-name + f.read().decode('utf-8')).group(1))) + + +def read_requirements(filename): + """ + Get application requirements from the requirements.txt file. + + :return: Python requirements + """ + with open(filename, 'r') as req: + requirements = req.readlines() + install_requires = [r.strip() for r in requirements if r.find('git+') != 0] + return install_requires + + +def read(filepath): + """ + Read the contents from a file. + + :param str filepath: path to the file to be read + :return: file contents + """ + with open(filepath, 'r') as file_handle: + content = file_handle.read() + return content + + +REQUIREMENTS = read_requirements('requirements.txt') + +setup( + name="entity_digital_credentials", + version=version, + author_email='akiff.manji@quartech.com', + packages=find_packages('src'), + package_dir={'': 'src'}, + py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], + include_package_data=True, + license=read('LICENSE'), + long_description=read('README.md'), + zip_safe=False, + install_requires=REQUIREMENTS, + setup_requires=['pytest-runner', ], + tests_require=['pytest', ], +) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/__init__.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/__init__.py new file mode 100644 index 0000000000..32a262926b --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/__init__.py @@ -0,0 +1,17 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Entity Digital Credentials service. + +This module is the service worker for issuing and revoking digital credentials for entity related events. +""" diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py new file mode 100644 index 0000000000..7cb1ef1f69 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/config.py @@ -0,0 +1,159 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""All of the configuration for the service is captured here. + +All items are loaded, or have Constants defined here that +are loaded into the Flask configuration. +All modules and lookups get their configuration from the +Flask config, rather than reading environment variables directly +or by accessing this configuration directly. +""" +import os +import random + +from dotenv import find_dotenv, load_dotenv + + +# this will load all the envars from a .env file located in the project root (api) +load_dotenv(find_dotenv()) + +CONFIGURATION = { + 'development': 'legal_api.config.DevConfig', + 'testing': 'legal_api.config.TestConfig', + 'production': 'legal_api.config.ProdConfig', + 'default': 'legal_api.config.ProdConfig' +} + + +def get_named_config(config_name: str = 'production'): + """Return the configuration object based on the name. + + :raise: KeyError: if an unknown configuration is requested + """ + if config_name in ['production', 'staging', 'default']: + config = ProdConfig() + elif config_name == 'testing': + config = TestConfig() + elif config_name == 'development': + config = DevConfig() + else: + raise KeyError(f'Unknown configuration: {config_name}') + return config + + +class _Config(): # pylint: disable=too-few-public-methods + """Base class configuration that should set reasonable defaults. + + Used as the base for all the other configurations. + """ + + PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) + + SENTRY_DSN = os.getenv('SENTRY_DSN', None) + LD_SDK_KEY = os.getenv('LD_SDK_KEY', None) + + # variables + LEGISLATIVE_TIMEZONE = os.getenv( + 'LEGISLATIVE_TIMEZONE', 'America/Vancouver') + + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # POSTGRESQL + DB_USER = os.getenv('ENTITY_DATABASE_USERNAME', '') + DB_PASSWORD = os.getenv('ENTITY_DATABASE_PASSWORD', '') + DB_NAME = os.getenv('ENTITY_DATABASE_NAME', '') + DB_HOST = os.getenv('ENTITY_DATABASE_HOST', '') + DB_PORT = os.getenv('ENTITY_DATABASE_PORT', '5432') + # pylint: disable=consider-using-f-string + SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( + user=DB_USER, + password=DB_PASSWORD, + host=DB_HOST, + port=int(DB_PORT), + name=DB_NAME, + ) + + NATS_CONNECTION_OPTIONS = { + 'servers': os.getenv('NATS_SERVERS', 'nats://127.0.0.1:4222').split(','), + 'name': os.getenv('NATS_CLIENT_NAME', 'entity.filing.worker') + + } + STAN_CONNECTION_OPTIONS = { + 'cluster_id': os.getenv('NATS_CLUSTER_ID', 'test-cluster'), + 'client_id': str(random.SystemRandom().getrandbits(0x58)), + 'ping_interval': 1, + 'ping_max_out': 5, + } + + SUBSCRIPTION_OPTIONS = { + 'subject': os.getenv('NATS_ENTITY_EVENT_SUBJECT', 'entity.events'), + 'queue': os.getenv('NATS_QUEUE', 'error'), + 'durable_name': os.getenv('NATS_QUEUE', 'error') + '_durable', + } + + ENTITY_EVENT_PUBLISH_OPTIONS = { + 'subject': os.getenv('NATS_ENTITY_EVENT_SUBJECT', 'entity.events'), + } + + # Traction ACA-Py tenant settings to issue credentials from + TRACTION_API_URL = os.getenv('TRACTION_API_URL') + TRACTION_TENANT_ID = os.getenv('TRACTION_TENANT_ID') + TRACTION_API_KEY = os.getenv('TRACTION_API_KEY') + TRACTION_PUBLIC_SCHEMA_DID = os.getenv('TRACTION_PUBLIC_SCHEMA_DID') + TRACTION_PUBLIC_ISSUER_DID = os.getenv('TRACTION_PUBLIC_ISSUER_DID') + + # Digital Business Card configuration values (required to issue credentials) + BUSINESS_SCHEMA_NAME = os.getenv('BUSINESS_SCHEMA_NAME') + BUSINESS_SCHEMA_VERSION = os.getenv('BUSINESS_SCHEMA_VERSION') + BUSINESS_SCHEMA_ID = os.getenv('BUSINESS_SCHEMA_ID') + BUSINESS_CRED_DEF_ID = os.getenv('BUSINESS_CRED_DEF_ID') + + +class DevConfig(_Config): # pylint: disable=too-few-public-methods + """Creates the Development Config object.""" + + TESTING = False + DEBUG = True + + +class TestConfig(_Config): # pylint: disable=too-few-public-methods + """In support of testing only. + + Used by the py.test suite + """ + + DEBUG = True + TESTING = True + # POSTGRESQL + DB_USER = os.getenv('DATABASE_TEST_USERNAME', '') + DB_PASSWORD = os.getenv('DATABASE_TEST_PASSWORD', '') + DB_NAME = os.getenv('DATABASE_TEST_NAME', '') + DB_HOST = os.getenv('DATABASE_TEST_HOST', '') + DB_PORT = os.getenv('DATABASE_TEST_PORT', '5432') + DEPLOYMENT_ENV = 'testing' + # pylint: disable=consider-using-f-string + SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( + user=DB_USER, + password=DB_PASSWORD, + host=DB_HOST, + port=int(DB_PORT), + name=DB_NAME, + ) + + +class ProdConfig(_Config): # pylint: disable=too-few-public-methods + """Production environment configuration.""" + + TESTING = False + DEBUG = False diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/__init__.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/__init__.py new file mode 100644 index 0000000000..755475504e --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/__init__.py @@ -0,0 +1,17 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Entity Digital Credentials service. + +This module contains processors for issuing and revoking digital credentials for entity related events. +""" diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/admin_revoke.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/admin_revoke.py new file mode 100644 index 0000000000..be9e493e8c --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/admin_revoke.py @@ -0,0 +1,32 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Processing admin revocation actions.""" + +from entity_queue_common.service_utils import logger +from legal_api.models import Business, DCRevocationReason + +from entity_digital_credentials.helpers import get_issued_digital_credentials, revoke_issued_digital_credential + + +async def process(business: Business): + """Process business number actions.""" + issued_credentials = get_issued_digital_credentials(business=business) + + if not (issued_credentials and len(issued_credentials)): + logger.warning('No issued credentials found for business: %s', business.identifier) + return None + + return revoke_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + reason=DCRevocationReason.ADMINISTRATIVE_REVOCATION) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py new file mode 100644 index 0000000000..87dc6253c1 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/business_number.py @@ -0,0 +1,33 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Processing business number actions.""" + +from entity_queue_common.service_utils import logger +from legal_api.models import Business, DCDefinition, DCRevocationReason + +from entity_digital_credentials.helpers import get_issued_digital_credentials, replace_issued_digital_credential + + +async def process(business: Business): + """Process business number actions.""" + issued_credentials = get_issued_digital_credentials(business=business) + + if not (issued_credentials and len(issued_credentials)): + logger.warning('No issued credentials found for business: %s', business.identifier) + return None + + return replace_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py new file mode 100644 index 0000000000..5f0d4227fa --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/change_of_registration.py @@ -0,0 +1,34 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Processing change of registration actions.""" + +from entity_queue_common.service_utils import logger +from legal_api.models import Business, DCDefinition, DCRevocationReason, Filing + +from entity_digital_credentials.helpers import get_issued_digital_credentials, replace_issued_digital_credential + + +async def process(business: Business, filing: Filing): + """Process change of registration actions.""" + if filing.filing_json.get('filing').get(filing.filing_type).get('nameRequest') is not None: + + issued_credentials = get_issued_digital_credentials(business=business) + if not (issued_credentials and len(issued_credentials)): + logger.warning('No issued credentials found for business: %s', business.identifier) + return None + + return replace_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py new file mode 100644 index 0000000000..9619955f5f --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/dissolution.py @@ -0,0 +1,46 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Processing dissolution actions.""" + +from entity_queue_common.service_utils import logger +from legal_api.models import Business, DCDefinition, DCRevocationReason + +from entity_digital_credentials.helpers import ( + get_issued_digital_credentials, + replace_issued_digital_credential, + revoke_issued_digital_credential, +) + + +async def process(business: Business, filing_sub_type: str): + """Process dissolution actions.""" + issued_credentials = get_issued_digital_credentials(business=business) + + if not (issued_credentials and len(issued_credentials)): + logger.warning('No issued credentials found for business: %s', business.identifier) + return None + + if filing_sub_type == 'voluntary': # pylint: disable=no-else-return + reason = DCRevocationReason.VOLUNTARY_DISSOLUTION + return replace_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + credential_type=DCDefinition.CredentialType.business.name, + reason=reason) + elif filing_sub_type == 'administrative': + reason = DCRevocationReason.ADMINISTRATIVE_DISSOLUTION + return revoke_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + reason=reason) + else: + raise Exception('Invalid filing sub type.') # pylint: disable=broad-exception-raised diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py new file mode 100644 index 0000000000..66768be9c1 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/digital_credentials_processors/put_back_on.py @@ -0,0 +1,33 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Processing put back on actions.""" + + +from entity_queue_common.service_utils import logger +from legal_api.models import Business, DCRevocationReason + +from entity_digital_credentials.helpers import get_issued_digital_credentials, revoke_issued_digital_credential + + +async def process(business: Business): + """Process put back on actions.""" + issued_credentials = get_issued_digital_credentials(business=business) + + if not (issued_credentials and len(issued_credentials)): + logger.warning('No issued credentials found for business: %s', business.identifier) + return None + + return revoke_issued_digital_credential(business=business, + issued_credential=issued_credentials[0], + reason=DCRevocationReason.PUT_BACK_ON) diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py new file mode 100644 index 0000000000..b173ffc086 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/helpers.py @@ -0,0 +1,143 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Helper functions for digital credentials.""" + +from legal_api.models import ( + Business, + DCConnection, + DCDefinition, + DCIssuedBusinessUserCredential, + DCIssuedCredential, + DCRevocationReason, + User, +) +from legal_api.services import digital_credentials +from legal_api.services.digital_credentials import DigitalCredentialsHelpers + + +def get_issued_digital_credentials(business: Business): + """Get issued digital credentials for a business.""" + try: + # pylint: disable=superfluous-parens + if not (connection := DCConnection.find_active_by(business_id=business.id)): + # pylint: disable=broad-exception-raised + raise Exception(f'{business.identifier} active connection not found.') + + # pylint: disable=superfluous-parens + if not (issued_credentials := DCIssuedCredential.find_by(dc_connection_id=connection.id)): + return [] + + return issued_credentials + # pylint: disable=broad-exception-raised + except Exception as err: # noqa: B902 + raise err + + +def issue_digital_credential(business: Business, user: User, credential_type: DCDefinition.credential_type): + """Issue a digital credential for a business to a user.""" + try: + if not (definition := DCDefinition.find_by(DCDefinition.CredentialType[credential_type], + digital_credentials.business_schema_id, + digital_credentials.business_cred_def_id)): + # pylint: disable=broad-exception-raised + raise Exception(f'Definition not found for credential type: {credential_type}.') + + # pylint: disable=superfluous-parens + if not (connection := DCConnection.find_active_by(business_id=business.id)): + # pylint: disable=broad-exception-raised + raise Exception(f'{business.identifier} active connection not found.') + + credential_data = DigitalCredentialsHelpers.get_digital_credential_data(business, + user, + definition.credential_type) + credential_id = next((item['value'] for item in credential_data if item['name'] == 'credential_id'), None) + + if not (response := digital_credentials.issue_credential(connection_id=connection.connection_id, + definition=definition, + data=credential_data)): + raise Exception('Failed to issue credential.') # pylint: disable=broad-exception-raised + + issued_credential = DCIssuedCredential( + dc_definition_id=definition.id, + dc_connection_id=connection.id, + credential_exchange_id=response['cred_ex_id'], + credential_id=credential_id + ) + issued_credential.save() + + return issued_credential + # pylint: disable=broad-exception-raised + except Exception as err: # noqa: B902 + raise err + + +def revoke_issued_digital_credential(business: Business, + issued_credential: DCIssuedCredential, + reason: DCRevocationReason): + """Revoke an issued digital credential for a business.""" + try: + if not issued_credential.is_issued or issued_credential.is_revoked: + # pylint: disable=broad-exception-raised + raise Exception('Credential is not issued yet or is revoked already.') + + # pylint: disable=superfluous-parens + if not (connection := DCConnection.find_active_by(business_id=business.id)): + # pylint: disable=broad-exception-raised + raise Exception(f'{business.identifier} active connection not found.') + + if (revoked := digital_credentials.revoke_credential(connection.connection_id, + issued_credential.credential_revocation_id, + issued_credential.revocation_registry_id, + reason) is None): + raise Exception('Failed to revoke credential.') # pylint: disable=broad-exception-raised + + issued_credential.is_revoked = True + issued_credential.save() + + return revoked + # pylint: disable=broad-exception-raised + except Exception as err: # noqa: B902 + raise err + + +def replace_issued_digital_credential(business: Business, + issued_credential: DCIssuedCredential, + credential_type: DCDefinition.CredentialType, + reason: DCRevocationReason): # pylint: disable=too-many-arguments + """Replace an issued digital credential for a business.""" + try: + if issued_credential.is_issued and not issued_credential.is_revoked: + revoke_issued_digital_credential(business, issued_credential, reason) + + if (digital_credentials.fetch_credential_exchange_record( + issued_credential.credential_exchange_id) is not None and + digital_credentials.remove_credential_exchange_record( + issued_credential.credential_exchange_id) is None): + raise Exception('Failed to remove credential exchange record.') # pylint: disable=broad-exception-raised + + if not (issued_business_user_credential := DCIssuedBusinessUserCredential.find_by_id( + dc_issued_business_user_id=issued_credential.credential_id)): + # pylint: disable=broad-exception-raised + raise Exception('Unable to find business user for issued credential.') + + if not (user := User.find_by_id(issued_business_user_credential.user_id)): # pylint: disable=superfluous-parens + # pylint: disable=broad-exception-raised + raise Exception('Unable to find user for issued business user credential.') + + issued_credential.delete() + + return issue_digital_credential(business, user, credential_type) + # pylint: disable=broad-exception-raised + except Exception as err: # noqa: B902 + raise err diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/version.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/version.py new file mode 100644 index 0000000000..10d8626102 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/version.py @@ -0,0 +1,25 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Version of this service in PEP440. + +[N!]N(.N)*[{a|b|rc}N][.postN][.devN] +Epoch segment: N! +Release segment: N(.N)* +Pre-release segment: {a|b|rc}N +Post-release segment: .postN +Development release segment: .devN +""" + +__version__ = '2.97.0' # pylint: disable=invalid-name diff --git a/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py new file mode 100644 index 0000000000..03decbfc55 --- /dev/null +++ b/queue_services/entity-digital-credentials/src/entity_digital_credentials/worker.py @@ -0,0 +1,163 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The unique worker functionality for this service is contained here. + +The entry-point is the **cb_subscription_handler** + +The design and flow leverage a few constraints that are placed upon it +by NATS Streaming and using AWAIT on the default loop. +- NATS streaming queues require one message to be processed at a time. +- AWAIT on the default loop effectively runs synchronously + +If these constraints change, the use of Flask-SQLAlchemy would need to change. +Flask-SQLAlchemy currently allows the base model to be changed, or reworking +the model to a standalone SQLAlchemy usage with an async engine would need +to be pursued. +""" +import json +import os +from enum import Enum + +import nats +from entity_queue_common.service import QueueServiceManager +from entity_queue_common.service_utils import QueueException, logger +from flask import Flask +from legal_api import db +from legal_api.core import Filing as FilingCore +from legal_api.models import Business +from legal_api.services import digital_credentials, flags +from sqlalchemy.exc import OperationalError + +from entity_digital_credentials import config +from entity_digital_credentials.digital_credentials_processors import ( + admin_revoke, + business_number, + change_of_registration, + dissolution, + put_back_on, +) + + +qsm = QueueServiceManager() # pylint: disable=invalid-name + +APP_CONFIG = config.get_named_config(os.getenv('DEPLOYMENT_ENV', 'production')) +FLASK_APP = Flask(__name__) +FLASK_APP.config.from_object(APP_CONFIG) +db.init_app(FLASK_APP) + +with FLASK_APP.app_context(): # db require app context + digital_credentials.init_app(FLASK_APP) + +if FLASK_APP.config.get('LD_SDK_KEY', None): + flags.init_app(FLASK_APP) + + +class AdminMessage(Enum): + """Entity Digital Credential admin message type.""" + + REVOKE = 'bc.registry.admin.revoke' + + +class BusinessMessage(Enum): + """Entity Digital Credential business message type.""" + + BN = 'bc.registry.business.bn' + CHANGE_OF_REGISTRATION = f'bc.registry.business.{FilingCore.FilingTypes.CHANGEOFREGISTRATION.value}' + DISSOLUTION = f'bc.registry.business.{FilingCore.FilingTypes.DISSOLUTION.value}' + PUT_BACK_ON = f'bc.registry.business.{FilingCore.FilingTypes.PUTBACKON.value}' + + +async def process_digital_credential(dc_msg: dict, flask_app: Flask): + # pylint: disable=too-many-branches, too-many-statements + """Process any digital credential messages in queue.""" + if not dc_msg or dc_msg.get('type') not in [ + BusinessMessage.CHANGE_OF_REGISTRATION.value, + BusinessMessage.DISSOLUTION.value, + BusinessMessage.PUT_BACK_ON.value, + BusinessMessage.BN.value, + AdminMessage.REVOKE.value + ]: + return None + + if not flask_app: + raise QueueException('Flask App not available.') + + with flask_app.app_context(): + logger.debug('Attempting to process digital credential message: %s', dc_msg) + + if dc_msg['type'] in (BusinessMessage.BN.value, AdminMessage.REVOKE.value): + # When a BN is added or changed or there is a manuak administrative update the queue message does not have + # a data object. We queue the business information using the identifier and revoke/reissue the credential + # immediately. + if dc_msg.get('identifier') is None: + raise QueueException('Digital credential message is missing identifier') + + identifier = dc_msg['identifier'] + if not (business := Business.find_by_identifier(identifier)): # pylint: disable=superfluous-parens + # pylint: disable=broad-exception-raised + raise Exception(f'Business with identifier: {identifier} not found.') + + if dc_msg['type'] == BusinessMessage.BN.value: + await business_number.process(business) + elif dc_msg['type'] == AdminMessage.REVOKE.value: + await admin_revoke.process(business) + else: + if dc_msg.get('data') is None \ + or dc_msg.get('data').get('filing') is None \ + or dc_msg.get('data').get('filing').get('header') is None \ + or dc_msg.get('data').get('filing').get('header').get('filingId') is None: + raise QueueException('Digital credential message is missing data.') + + filing_id = dc_msg['data']['filing']['header']['filingId'] + + if not (filing_core := FilingCore.find_by_id(filing_id)): # pylint: disable=superfluous-parens + raise QueueException(f'Filing not found for id: {filing_id}.') + + if not (filing := filing_core.storage): # pylint: disable=superfluous-parens + raise QueueException(f'Filing not found for id: {filing_id}.') + + if filing.status != FilingCore.Status.COMPLETED.value: + raise QueueException(f'Filing with id: {filing_id} processing not complete.') + + business_id = filing.business_id + if not (business := Business.find_by_internal_id(business_id)): # pylint: disable=superfluous-parens + # pylint: disable=broad-exception-raised + raise Exception(f'Business with internal id: {business_id} not found.') + + # Process individual filing events + if filing.filing_type == FilingCore.FilingTypes.CHANGEOFREGISTRATION.value: + await change_of_registration.process(business, filing) + if filing.filing_type == FilingCore.FilingTypes.DISSOLUTION.value: + filing_sub_type = filing.filing_sub_type + await dissolution.process(business, filing_sub_type) # pylint: disable=too-many-function-args + if filing.filing_type == FilingCore.FilingTypes.PUTBACKON.value: + await put_back_on.process(business) + + +async def cb_subscription_handler(msg: nats.aio.client.Msg): + """Use Callback to process Queue Msg objects.""" + with FLASK_APP.app_context(): + try: + logger.info('Received raw message seq: %s, data= %s', + msg.sequence, msg.data.decode()) + dc_msg = json.loads(msg.data.decode('utf-8')) + logger.debug('Extracted digital credential msg: %s', dc_msg) + await process_digital_credential(dc_msg, FLASK_APP) + except OperationalError as err: + logger.error('Queue Blocked - Database Issue: %s', + json.dumps(dc_msg), exc_info=True) + raise err # We don't want to handle the error, as a DB down would drain the queue + except (QueueException, Exception) as err: # noqa B902; pylint: disable=W0703, disable=unused-variable + # Catch Exception so that any error is still caught and the message is removed from the queue + logger.error('Queue Error: %s', json.dumps(dc_msg), exc_info=True) diff --git a/queue_services/entity-digital-credentials/tests/__init__.py b/queue_services/entity-digital-credentials/tests/__init__.py new file mode 100644 index 0000000000..b3df612f9e --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/__init__.py @@ -0,0 +1,14 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Test Suites to ensure that the service is built and operating correctly.""" diff --git a/queue_services/entity-digital-credentials/tests/conftest.py b/queue_services/entity-digital-credentials/tests/conftest.py new file mode 100644 index 0000000000..dd58b16c81 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/conftest.py @@ -0,0 +1,121 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Fixutres for the digital credentials queue are contained here.""" + +import os + +import pytest +from flask import Flask +from flask_migrate import Migrate, upgrade +from legal_api import db as _db +from sqlalchemy import event, text +from sqlalchemy.schema import MetaData + +from entity_digital_credentials.config import get_named_config + + +# Fixtures +@pytest.fixture(scope='session') +def app(): + """Return a session-wide application configured in TEST mode.""" + _app = Flask(__name__) + _app.config.from_object(get_named_config('testing')) + _db.init_app(_app) + + return _app + + +@pytest.fixture(scope='session') +def db(app): # pylint: disable=redefined-outer-name, invalid-name + """Return a session-wide initialised database. + + Drops all existing tables - Meta follows Postgres FKs + """ + with app.app_context(): + # Clear out any existing tables + metadata = MetaData(_db.engine) + metadata.reflect() + metadata.drop_all() + _db.drop_all() + + sequence_sql = """SELECT sequence_name FROM information_schema.sequences + WHERE sequence_schema='public' + """ + + sess = _db.session() + for seq in [name for (name,) in sess.execute(text(sequence_sql))]: + try: + sess.execute(text('DROP SEQUENCE public.%s ;' % seq)) + print('DROP SEQUENCE public.%s ' % seq) + except Exception as err: # pylint: disable=broad-except; # noqa: B902 + print(f'Error: {err}') + sess.commit() + + # ############################################ + # There are 2 approaches, an empty database, or the same one that the app will use + # create the tables + # _db.create_all() + # or + # Use Alembic to load all of the DB revisions including supporting lookup data + # This is the path we'll use in legal_api!! + + # even though this isn't referenced directly, it sets up the internal configs that upgrade needs + legal_api_dir = os.path.abspath('..').replace('queue_services', 'legal-api') + legal_api_dir = os.path.join(legal_api_dir, 'migrations') + Migrate(app, _db, directory=legal_api_dir) + upgrade() + + return _db + + +@pytest.fixture(scope='function') +def session(app, db): # pylint: disable=redefined-outer-name, invalid-name + """Return a function-scoped session.""" + with app.app_context(): + conn = db.engine.connect() + txn = conn.begin() + + options = dict(bind=conn, binds={}) + sess = db.create_scoped_session(options=options) + + # For those who have local databases on bare metal in local time. + # Otherwise some of the returns will come back in local time and unit tests will fail. + # The current DEV database uses UTC. + sess.execute("SET TIME ZONE 'UTC';") + sess.commit() + + # establish a SAVEPOINT just before beginning the test + # (http://docs.sqlalchemy.org/en/latest/orm/session_transaction.html#using-savepoint) + sess.begin_nested() + + @event.listens_for(sess(), 'after_transaction_end') + def restart_savepoint(sess2, trans): # pylint: disable=unused-variable + # Detecting whether this is indeed the nested transaction of the test + if trans.nested and not trans._parent.nested: # pylint: disable=protected-access + # Handle where test DOESN'T session.commit(), + sess2.expire_all() + sess.begin_nested() + + db.session = sess + + sql = text('select 1') + sess.execute(sql) + + yield sess + + # Cleanup + sess.remove() + # This instruction rollsback any commit that were executed in the tests. + txn.rollback() + conn.close() diff --git a/queue_services/entity-digital-credentials/tests/unit/__init__.py b/queue_services/entity-digital-credentials/tests/unit/__init__.py new file mode 100644 index 0000000000..42f5b8a031 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/__init__.py @@ -0,0 +1,102 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Unit Tests and the helper routines.""" + +from legal_api.models import Business, DCConnection, DCDefinition, DCIssuedCredential, Filing +from sqlalchemy_continuum import versioning_manager + + +def create_business(identifier): + """Return a test business.""" + business = Business() + business.identifier = identifier + business.legal_type = Business.LegalTypes.SOLE_PROP + business.legal_name = 'test_business' + business.save() + return business + + +def create_filing(session, business_id=None, + filing_json=None, filing_type=None, + filing_status=Filing.Status.COMPLETED.value): + """Return a test filing.""" + filing = Filing() + filing._filing_type = filing_type + filing._filing_sub_type = 'test' + filing._status = filing_status + + if filing_status == Filing.Status.COMPLETED.value: + uow = versioning_manager.unit_of_work(session) + transaction = uow.create_transaction(session) + filing.transaction_id = transaction.id + if filing_json: + filing.filing_json = filing_json + if business_id: + filing.business_id = business_id + + filing.save() + return filing + + +def create_dc_definition(): + """Create new dc_definition object.""" + definition = DCDefinition( + credential_type=DCDefinition.CredentialType.business.name, + schema_name='test_business_schema', + schema_version='1.0.0', + schema_id='test_schema_id', + credential_definition_id='test_credential_definition_id' + ) + definition.save() + return definition + + +def create_dc_connection(business, is_active=False): + """Create new dc_connection object.""" + connection = DCConnection( + connection_id='0d94e18b-3a52-4122-8adf-33e2ccff681f', + invitation_url="""http://192.168.65.3:8020?c_i=eyJAdHlwZSI6ICJodHRwczovL2RpZGNvbW0ub3JnL2Nvbm5lY3Rpb +25zLzEuMC9pbnZpdGF0aW9uIiwgIkBpZCI6ICIyZjU1M2JkZS01YWJlLTRkZDctODIwZi1mNWQ2Mjc1OWQxODgi +LCAicmVjaXBpZW50S2V5cyI6IFsiMkFHSjVrRDlVYU45OVpSeUFHZVZKNDkxclZhNzZwZGZYdkxXZkFyc2lKWjY +iXSwgImxhYmVsIjogImZhYmVyLmFnZW50IiwgInNlcnZpY2VFbmRwb2ludCI6ICJodHRwOi8vMTkyLjE2OC42NS4zOjgwMjAifQ==""", + is_active=is_active, + connection_state='active' if is_active else 'invitation', + business_id=business.id + ) + connection.save() + return connection + + +def create_dc_issued_credential(business=None, + credential_exchange_id='test_credential_exchange_id', + credential_revocation_id='123', + revocation_registry_id='123', + is_issued=True, is_revoked=False): + """Create new dc_issued_credential object.""" + if not business: + identifier = 'FM1234567' + business = create_business(identifier) + definition = create_dc_definition() + connection = create_dc_connection(business, is_active=True) + issued_credential = DCIssuedCredential( + dc_definition_id=definition.id, + dc_connection_id=connection.id, + credential_exchange_id=credential_exchange_id, + credential_revocation_id=credential_revocation_id, + revocation_registry_id=revocation_registry_id, + is_issued=is_issued, + is_revoked=is_revoked + ) + issued_credential.save() + return issued_credential diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_admin_revoke.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_admin_revoke.py new file mode 100644 index 0000000000..bc18b30396 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_admin_revoke.py @@ -0,0 +1,65 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the admin revocation processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCRevocationReason + +from entity_digital_credentials.digital_credentials_processors.admin_revoke import process +from tests.unit import create_business + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.logger') +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.revoke_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_revoke_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_revoke_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.revoke_issued_digital_credential') +async def test_processor_revokes_issued_credential(mock_revoke_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor revokes the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_revoke_issued_digital_credential.assert_called_once_with(business=business, + issued_credential={'id': 1}, + reason=DCRevocationReason.ADMINISTRATIVE_REVOCATION) diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_business_number.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_business_number.py new file mode 100644 index 0000000000..5a2ca49aa5 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_business_number.py @@ -0,0 +1,67 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the business number processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCDefinition, DCRevocationReason + +from entity_digital_credentials.digital_credentials_processors.business_number import process +from tests.unit import create_business + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.business_number.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.business_number.logger') +@patch('entity_digital_credentials.digital_credentials_processors.business_number.replace_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_replace_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_replace_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.business_number.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.business_number.replace_issued_digital_credential') +async def test_processor_replaces_issued_credential(mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor replaces the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_replace_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_change_of_registration.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_change_of_registration.py new file mode 100644 index 0000000000..897b73605f --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_change_of_registration.py @@ -0,0 +1,122 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the change of registration processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCDefinition, DCRevocationReason, Filing + +from entity_digital_credentials.digital_credentials_processors.change_of_registration import process +from tests.unit import create_business, create_filing + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.change_of_registration.logger') +@patch('entity_digital_credentials.digital_credentials_processors.' + + 'change_of_registration.replace_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_replace_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + filing = create_filing(session, business.id, { + 'filing': { + 'header': { + 'name': 'changeOfRegistration', + 'filingId': None + }, + 'changeOfRegistration': { + 'nameRequest': {} + } + }}, 'test', Filing.Status.COMPLETED.value) + + # Act + await process(business, filing) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_replace_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.replace_issued_digital_credential') +async def test_processor_does_not_run_if_invalid_typel(mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if not the right type.""" + # Arrange + business = create_business(identifier='FM0000001') + filing = create_filing(session, business.id, { + 'filing': { + 'header': { + 'name': 'changeOfRegistration', + 'filingId': None + }, + 'changeOfRegistration': { + 'test': {} + } + }}, 'changeOfRegistration', Filing.Status.COMPLETED.value) + + # Act + await process(business, filing) + + # Assert + mock_get_issued_digital_credentials.assert_not_called() + mock_replace_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors' + + '.change_of_registration.replace_issued_digital_credential') +async def test_processor_replaces_issued_credential(mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor replaces the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + filing = create_filing(session, business.id, { + 'filing': { + 'header': { + 'name': 'changeOfRegistration', + 'filingId': None + }, + 'changeOfRegistration': { + 'nameRequest': {} + } + }}, 'changeOfRegistration', Filing.Status.COMPLETED.value) + + # Act + await process(business, filing) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_replace_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.UPDATED_INFORMATION) diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_dissolution.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_dissolution.py new file mode 100644 index 0000000000..1af35dd441 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_dissolution.py @@ -0,0 +1,122 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the dissolution processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCDefinition, DCRevocationReason + +from entity_digital_credentials.digital_credentials_processors.dissolution import process +from tests.unit import create_business + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.logger') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.replace_issued_digital_credential') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.revoke_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_revoke_issued_digital_credential, + mock_replace_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business, 'test') + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_replace_issued_digital_credential.assert_not_called() + mock_revoke_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.replace_issued_digital_credential') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.revoke_issued_digital_credential') +async def test_processor_does_not_run_if_invalid_sub_type(mock_revoke_issued_digital_credential, + mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if an invalid sub type provided.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + with pytest.raises(Exception) as excinfo: + await process(business, 'test') + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_replace_issued_digital_credential.assert_not_called() + mock_revoke_issued_digital_credential.assert_not_called() + assert 'Invalid filing sub type.' in str(excinfo) + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.replace_issued_digital_credential') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.revoke_issued_digital_credential') +async def test_processor_replaces_issued_credential(mock_revoke_issued_digital_credential, + mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor replaces the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business, 'voluntary') + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_replace_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + credential_type=DCDefinition.CredentialType.business.name, + reason=DCRevocationReason.VOLUNTARY_DISSOLUTION) + mock_revoke_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.replace_issued_digital_credential') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.revoke_issued_digital_credential') +async def test_processor_revokes_issued_credential(mock_revoke_issued_digital_credential, + mock_replace_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor revokes the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business, 'administrative') + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_revoke_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + reason=DCRevocationReason.ADMINISTRATIVE_DISSOLUTION) + mock_replace_issued_digital_credential.assert_not_called() diff --git a/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_put_back_on.py b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_put_back_on.py new file mode 100644 index 0000000000..ac6e84d096 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/digital_credentials_processors/test_put_back_on.py @@ -0,0 +1,66 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the put back on processor are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import DCRevocationReason + +from entity_digital_credentials.digital_credentials_processors.put_back_on import process +from tests.unit import create_business + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.get_issued_digital_credentials', + return_value=[]) +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.logger') +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.revoke_issued_digital_credential') +async def test_processor_does_not_run_if_no_issued_credential(mock_revoke_issued_digital_credential, + mock_logger, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor does not run if the current business has no issued credentials.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_logger.warning.assert_called_once_with('No issued credentials found for business: %s', 'FM0000001') + mock_revoke_issued_digital_credential.assert_not_called() + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.get_issued_digital_credentials', + return_value=[{'id': 1}]) +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.revoke_issued_digital_credential') +async def test_processor_revokes_issued_credential(mock_revoke_issued_digital_credential, + mock_get_issued_digital_credentials, + app, session): + """Assert that the processor revokes the issued credential if it exists.""" + # Arrange + business = create_business(identifier='FM0000001') + + # Act + await process(business) + + # Assert + mock_get_issued_digital_credentials.assert_called_once_with(business=business) + mock_revoke_issued_digital_credential.assert_called_once_with( + business=business, + issued_credential={'id': 1}, + reason=DCRevocationReason.PUT_BACK_ON) diff --git a/queue_services/entity-digital-credentials/tests/unit/test_helpers.py b/queue_services/entity-digital-credentials/tests/unit/test_helpers.py new file mode 100644 index 0000000000..6d1f88d6a7 --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/test_helpers.py @@ -0,0 +1,423 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the helper functions are contained here.""" + + +from unittest.mock import patch + +import pytest +from legal_api.models import ( + DCConnection, + DCDefinition, + DCIssuedBusinessUserCredential, + DCIssuedCredential, + DCRevocationReason, + User, +) + +from entity_digital_credentials.helpers import ( + get_issued_digital_credentials, + issue_digital_credential, + replace_issued_digital_credential, + revoke_issued_digital_credential, +) +from tests.unit import create_business, create_dc_connection, create_dc_definition, create_dc_issued_credential + + +BUSINESS_IDENTIFIER = 'FM0000001' + + +@patch('legal_api.models.DCIssuedCredential.find_by', return_value=DCIssuedCredential(id=1)) +@patch('legal_api.models.DCConnection.find_active_by', return_value=None) +def test_get_issued_digital_credentials_raises_exception(mock_find_active_by, mock_find_by, app, session): + """Assert get_issued_digital_credentials raises an exception when no active connection found.""" + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + + # Act + with pytest.raises(Exception) as excinfo: + get_issued_digital_credentials(business=business) + + # Assert + assert f'{BUSINESS_IDENTIFIER} active connection not found.' in str(excinfo) + + +@patch('legal_api.models.DCIssuedCredential.find_by', return_value=None) +@patch('legal_api.models.DCConnection.find_active_by', return_value=DCConnection(id=1)) +def test_get_issued_credentials_returns_empty_list(mock_find_active_by, mock_find_by, app, session): + """Assert get_issued_digital_credentials returns an empty list when no issued credentials found.""" + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + + # Act + issued_credentials = get_issued_digital_credentials(business=business) + + # Assert + assert issued_credentials == [] + + +@patch('legal_api.services.digital_credentials.revoke_credential') +def test_issued_credential_not_issued_not_revoked(mock_revoke_credential, app, session): + """Assert that the issued credential is not revoked if is not yet issued.""" + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=False) + + # Act + with pytest.raises(Exception) as excinfo: + revoke_issued_digital_credential(business=business, + issued_credential=issued_credential, + reason=DCRevocationReason.UPDATED_INFORMATION) + # Assert + assert 'Credential is not issued yet or is revoked already.' in str(excinfo) + mock_revoke_credential.assert_not_called() + + +@patch('legal_api.services.digital_credentials.revoke_credential') +def test_issued_credential_already_revoked_not_revoked(mock_revoke_credential, app, session): + """Assert that the issued credential is not revoked if already revoked.""" + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=True) + + # Act + with pytest.raises(Exception) as excinfo: + revoke_issued_digital_credential(business=business, + issued_credential=issued_credential, + reason=DCRevocationReason.UPDATED_INFORMATION) + # Assert + assert 'Credential is not issued yet or is revoked already.' in str(excinfo) + mock_revoke_credential.assert_not_called() + + +@patch('legal_api.models.DCConnection.find_active_by', return_value=None) +@patch('legal_api.services.digital_credentials.revoke_credential') +def test_issued_credential_no_active_connection_not_revoked(mock_revoke_credential, mock_find_active_by, + app, session): + """Assert that the issued credential is not revoked if no active connection found.""" + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=False) + + # Act + with pytest.raises(Exception) as excinfo: + revoke_issued_digital_credential(business=business, + issued_credential=issued_credential, + reason=DCRevocationReason.UPDATED_INFORMATION) + # Assert + assert f'{BUSINESS_IDENTIFIER} active connection not found.' in str(excinfo) + mock_revoke_credential.assert_not_called() + + +@patch('legal_api.services.digital_credentials.revoke_credential', return_value=None) +def test_revoke_issued_digital_credential_helper_throws_exception(mock_revoke_credential, app, session): + """Assert that the revoke issued credential helper throws an exception if the service fails.""" + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=False) + + # Act + with pytest.raises(Exception) as excinfo: + revoke_issued_digital_credential(business=business, + issued_credential=issued_credential, + reason=DCRevocationReason.UPDATED_INFORMATION) + + # Assert + assert 'Failed to revoke credential.' in str(excinfo) + assert issued_credential.is_revoked is False + + +@patch('legal_api.services.digital_credentials.revoke_credential', return_value={}) +def test_issued_credential_revoked(mock_revoke_credential, app, session): + """Assert that the issued credential is revoked.""" + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=False) + + # Act + revoke_issued_digital_credential(business=business, + issued_credential=issued_credential, + reason=DCRevocationReason.UPDATED_INFORMATION) + + # Assert + assert issued_credential.is_revoked is True + + +@patch('entity_digital_credentials.helpers.issue_digital_credential', return_value=None) +@patch('legal_api.services.digital_credentials.fetch_credential_exchange_record', return_value=None) +@patch('legal_api.models.User.find_by_id', return_value=User(id=1)) +@patch('legal_api.models.DCIssuedBusinessUserCredential.find_by_id', + return_value=DCIssuedBusinessUserCredential(id=1, user_id=1)) +@patch('entity_digital_credentials.helpers.revoke_issued_digital_credential') +def test_issued_credential_not_revoked_is_revoked_first(mock_revoke_credential, + mock_find_ibuc_by_id, + mock_find_user_by_id, + mock_fetch_credential_exchange_record, + mock_issue_digital_credential, + app, session): + """Assert that the issued credential is revoked first if its not revoked before replacing.""" + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=False) + reason = DCRevocationReason.UPDATED_INFORMATION + + # Act + replace_issued_digital_credential(business=business, + issued_credential=issued_credential, + credential_type=DCDefinition.CredentialType.business.name, + reason=reason) + + # Assert + mock_revoke_credential.assert_called_once_with(business, issued_credential, reason) + + +@patch('entity_digital_credentials.helpers.issue_digital_credential', return_value=None) +@patch('legal_api.services.digital_credentials.fetch_credential_exchange_record', return_value=None) +@patch('legal_api.models.User.find_by_id', return_value=User(id=1)) +@patch('legal_api.models.DCIssuedBusinessUserCredential.find_by_id', + return_value=DCIssuedBusinessUserCredential(id=1, user_id=1)) +@patch('entity_digital_credentials.helpers.revoke_issued_digital_credential') +def test_issued_credential_revoked_is_not_revoked_first(mock_revoke_credential, + mock_find_ibuc_by_id, + mock_find_user_by_id, + mock_fetch_credential_exchange_record, + mock_issue_digital_credential, + app, session): + """Assert that the issued credential is not revoked first if its already revoked before replacing.""" + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=True) + reason = DCRevocationReason.UPDATED_INFORMATION + + # Act + replace_issued_digital_credential(business=business, + issued_credential=issued_credential, + credential_type=DCDefinition.CredentialType.business.name, + reason=reason) + + # Assert + mock_revoke_credential.assert_not_called() + + +@patch('entity_digital_credentials.helpers.issue_digital_credential') +@patch('legal_api.services.digital_credentials.fetch_credential_exchange_record', + return_value='test_credential_exchange_id') +@patch('legal_api.services.digital_credentials.remove_credential_exchange_record', return_value=None) +def test_replace_issued_digital_credential_throws_cred_ex_id_exception(mock_remove_credential_exchange_record, + mock_fetch_credential_exchange_record, + mock_issue_digital_credential, + app, session): + """ + Assert the digital credential credential service throws an exception. + + An exception should be thrown if the service fails to remove a credential exchange id. + """ + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=True) + reason = DCRevocationReason.UPDATED_INFORMATION + + # Act + with pytest.raises(Exception) as excinfo: + replace_issued_digital_credential(business=business, + issued_credential=issued_credential, + credential_type=DCDefinition.CredentialType.business.name, + reason=reason) + + # Assert + assert 'Failed to remove credential exchange record.' in str(excinfo) + mock_issue_digital_credential.assert_not_called() + + +@patch('entity_digital_credentials.helpers.issue_digital_credential') +@patch('legal_api.services.digital_credentials.fetch_credential_exchange_record', return_value=None) +@patch('legal_api.models.DCIssuedBusinessUserCredential.find_by_id', return_value=None) +def test_replace_issued_digital_credential_throws_ibuc_not_found_exception(mock_find_ibuc_by_id, + mock_fetch_credential_exchange_record, + mock_issue_digital_credential, + app, session): + """ + Assert the digital credential credential service throws an exception. + + An exception should be thrown if the issued business user credential is not found. + """ + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=True) + reason = DCRevocationReason.UPDATED_INFORMATION + + # Act + with pytest.raises(Exception) as excinfo: + replace_issued_digital_credential(business=business, + issued_credential=issued_credential, + credential_type=DCDefinition.CredentialType.business.name, + reason=reason) + + # Assert + assert 'Unable to find business user for issued credential.' in str(excinfo) + mock_issue_digital_credential.assert_not_called() + + +@patch('entity_digital_credentials.helpers.issue_digital_credential') +@patch('legal_api.services.digital_credentials.fetch_credential_exchange_record', return_value=None) +@patch('legal_api.models.User.find_by_id', return_value=None) +@patch('legal_api.models.DCIssuedBusinessUserCredential.find_by_id', + return_value=DCIssuedBusinessUserCredential(id=1, user_id=1)) +def test_replace_issued_digital_credential_throws_user_not_found_exception(mock_find_ibuc_by_id, + mock_find_user_by_id, + mock_fetch_credential_exchange_record, + mock_issue_digital_credential, + app, session): + """ + Assert the digital credential credential service throws an exception. + + An exception should be thrown if the user is not found. + """ + # Arrange + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=True) + reason = DCRevocationReason.UPDATED_INFORMATION + + # Act + with pytest.raises(Exception) as excinfo: + replace_issued_digital_credential(business=business, + issued_credential=issued_credential, + credential_type=DCDefinition.CredentialType.business.name, + reason=reason) + + # Assert + assert 'Unable to find user for issued business user credential.' in str(excinfo) + mock_issue_digital_credential.assert_not_called() + + +@patch('entity_digital_credentials.helpers.issue_digital_credential', return_value=None) +@patch('legal_api.services.digital_credentials.fetch_credential_exchange_record', return_value=None) +@patch('legal_api.models.User.find_by_id', return_value=User(id=1)) +@patch('legal_api.models.DCIssuedBusinessUserCredential.find_by_id', + return_value=DCIssuedBusinessUserCredential(id=1, user_id=1)) +def test_issued_credential_replaced(mock_find_ibuc_by_id, + mock_find_user_by_id, + mock_fetch_credential_exchange_record, + mock_issue_digital_credential, + app, session): + """Assert that the issued credential is deleted and replaced with a new one.""" + # Arrange + user = User.find_by_id(1) + business = create_business(identifier=BUSINESS_IDENTIFIER) + issued_credential = create_dc_issued_credential(business=business, is_issued=True, is_revoked=True) + credential_type = DCDefinition.CredentialType.business.name + reason = DCRevocationReason.UPDATED_INFORMATION + + # Act + replace_issued_digital_credential(business=business, + issued_credential=issued_credential, + credential_type=credential_type, + reason=reason) + + # Assert + assert DCIssuedCredential.find_by_id(issued_credential.id) is None + mock_issue_digital_credential.assert_called_once_with(business, user, credential_type) + + +@patch('legal_api.models.DCDefinition.find_by', return_value=None) +def test_issue_digital_credential_throws_definition_not_found_error(mock_find_definition_by, app, session): + """Assert that the issue_digital_credential helper throws an exception if the definition is not found.""" + # Arrange + user = User(id=1) + business = create_business(identifier=BUSINESS_IDENTIFIER) + definition = create_dc_definition() + + # Act + with pytest.raises(Exception) as excinfo: + issue_digital_credential(business=business, user=user, credential_type=definition.credential_type.name) + + # Assert + assert 'Definition not found for credential type: business.' in str(excinfo) + + +@patch('legal_api.models.DCConnection.find_active_by', return_value=None) +@patch('entity_digital_credentials.helpers.digital_credentials') +def test_issue_digital_credential_throws_active_connection_not_found_error(mock_digital_credentials, + mock_find_active_by, + app, session): + """Assert that the issue_digital_credential helper throws an exception if the definition is not found.""" + # Arrange + mock_digital_credentials.business_schema_id = 'test_schema_id' + mock_digital_credentials.business_cred_def_id = 'test_credential_definition_id' + user = User(id=1) + business = create_business(identifier=BUSINESS_IDENTIFIER) + definition = create_dc_definition() + + # Act + with pytest.raises(Exception) as excinfo: + issue_digital_credential(business=business, user=user, credential_type=definition.credential_type.name) + + # Assert + assert f'{BUSINESS_IDENTIFIER} active connection not found.' in str(excinfo) + + +@patch('entity_digital_credentials.helpers.digital_credentials') +@patch('entity_digital_credentials.helpers.DigitalCredentialsHelpers.get_digital_credential_data', return_value=[{ + 'name': 'credential_id', + 'value': '00000001' +}]) +def test_issue_digital_credential_throws_exception_on_failure(mock_digital_credentials_helpers, + mock_digital_credentials, + app, session): + """Assert that the issue_digital_credential helper throws an exception if the service fails.""" + # Arrange + mock_digital_credentials.issue_credential.return_value = None + mock_digital_credentials.business_schema_id = 'test_schema_id' + mock_digital_credentials.business_cred_def_id = 'test_credential_definition_id' + user = User(id=1) + business = create_business(identifier=BUSINESS_IDENTIFIER) + definition = create_dc_definition() + create_dc_connection(business=business, is_active=True) + + # Act + with pytest.raises(Exception) as excinfo: + issue_digital_credential(business=business, user=user, credential_type=definition.credential_type.name) + + # Assert + assert 'Failed to issue credential.' in str(excinfo) + + +@patch('entity_digital_credentials.helpers.digital_credentials') +@patch('entity_digital_credentials.helpers.DigitalCredentialsHelpers.get_digital_credential_data', return_value=[{ + 'name': 'credential_id', + 'value': '00000001' +}]) +def test_issue_digital_credential(mock_digital_credentials_helpers, + mock_digital_credentials, + app, session): + """Assert that the issue_digital_credential helper issues a credential.""" + # Arrange + mock_digital_credentials.issue_credential.return_value = {'cred_ex_id': 'test_credential_exchange_id'} + mock_digital_credentials.business_schema_id = 'test_schema_id' + mock_digital_credentials.business_cred_def_id = 'test_credential_definition_id' + user = User(id=1) + business = create_business(identifier=BUSINESS_IDENTIFIER) + definition = create_dc_definition() + connection = create_dc_connection(business=business, is_active=True) + + # Act + issued_credential = issue_digital_credential(business=business, + user=user, + credential_type=definition.credential_type.name) + + # Assert + assert issued_credential.credential_exchange_id == 'test_credential_exchange_id' + assert issued_credential.credential_id == '00000001' + assert issued_credential.dc_definition_id == definition.id + assert issued_credential.dc_connection_id == connection.id diff --git a/queue_services/entity-digital-credentials/tests/unit/test_worker.py b/queue_services/entity-digital-credentials/tests/unit/test_worker.py new file mode 100644 index 0000000000..4baafb89da --- /dev/null +++ b/queue_services/entity-digital-credentials/tests/unit/test_worker.py @@ -0,0 +1,225 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the queue worker are contained here.""" + +from unittest.mock import patch + +import pytest +from legal_api.models import Filing + +from entity_digital_credentials.worker import process_digital_credential +from tests.unit import create_business, create_filing + + +ADMIN_REVOKE = 'bc.registry.admin.revoke' +BUSINESS_NUMBER = 'bc.registry.business.bn' +CHANGE_OF_REGISTRATION = 'bc.registry.business.changeOfRegistration' +DISSOLUTION = 'bc.registry.business.dissolution' +PUT_BACK_ON = 'bc.registry.business.putBackOn' + + +@pytest.mark.asyncio +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.process') +@patch('entity_digital_credentials.digital_credentials_processors.business_number.process') +@patch('entity_digital_credentials.digital_credentials_processors.change_of_registration.process') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.process') +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.process') +async def test_processes_not_run(mock_put_back_on, mock_dissolution, mock_change_of_registration, + mock_business_number, mock_admin_revoke, app, session): + """Assert processors are not called if message type is not supported.""" + # Arrange + dc_msg = {'type': 'bc.registry.business.test', 'identifier': 'FM0000001'} + + # Act + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + mock_admin_revoke.assert_not_called() + mock_business_number.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_dissolution.assert_not_called() + mock_put_back_on.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dc_msg', [{ + 'type': ADMIN_REVOKE, + 'identifier': 'FM0000001' +}, { + 'type': BUSINESS_NUMBER, + 'identifier': 'FM0000002' +}]) +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.process') +@patch('entity_digital_credentials.digital_credentials_processors.business_number.process') +@patch('entity_digital_credentials.digital_credentials_processors.change_of_registration.process') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.process') +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.process') +async def test_processes_no_filing_required(mock_put_back_on, mock_dissolution, mock_change_of_registration, + mock_business_number, mock_admin_revoke, dc_msg, app, session): + """Assert processor runs if given the right message type.""" + # Arrange + business = create_business(dc_msg['identifier']) + + # Act + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + if dc_msg['type'] == ADMIN_REVOKE: + mock_admin_revoke.assert_called_once() + assert business.identifier == 'FM0000001' + mock_admin_revoke.assert_called_with(business) + + # Other processors should not be called + mock_business_number.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_dissolution.assert_not_called() + mock_put_back_on.assert_not_called() + elif dc_msg['type'] == BUSINESS_NUMBER: + mock_business_number.assert_called_once() + assert business.identifier == 'FM0000002' + mock_business_number.assert_called_with(business) + + # Other processors should not be called + mock_admin_revoke.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_dissolution.assert_not_called() + mock_put_back_on.assert_not_called() + else: + assert False + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dc_msg', [{ + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001', + 'data': {'filing': {'header': {'filingId': None}}} +}, { + 'type': DISSOLUTION, + 'identifier': 'FM0000002', + 'data': {'filing': {'header': {'filingId': None}}} +}, { + 'type': PUT_BACK_ON, + 'identifier': 'FM0000003', + 'data': {'filing': {'header': {'filingId': None}}} +}]) +@patch('entity_digital_credentials.digital_credentials_processors.admin_revoke.process') +@patch('entity_digital_credentials.digital_credentials_processors.business_number.process') +@patch('entity_digital_credentials.digital_credentials_processors.change_of_registration.process') +@patch('entity_digital_credentials.digital_credentials_processors.dissolution.process') +@patch('entity_digital_credentials.digital_credentials_processors.put_back_on.process') +async def test_processes_filing_required(mock_put_back_on, mock_dissolution, mock_change_of_registration, + mock_business_number, mock_admin_revoke, dc_msg, app, session): + """Assert processor runs if given the right message type.""" + # Arrange + business = create_business(dc_msg['identifier']) + filing_type = dc_msg['type'].replace('bc.registry.business.', '') + filing = create_filing(session, business.id, None, filing_type, Filing.Status.COMPLETED.value) + dc_msg['data']['filing']['header']['filingId'] = filing.id + + # Act + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + if dc_msg['type'] == CHANGE_OF_REGISTRATION: + mock_change_of_registration.assert_called_once() + assert business.identifier == 'FM0000001' + mock_change_of_registration.assert_called_with(business, filing) + + # Other processors should not be called + mock_admin_revoke.assert_not_called() + mock_business_number.assert_not_called() + mock_dissolution.assert_not_called() + mock_put_back_on.assert_not_called() + elif dc_msg['type'] == DISSOLUTION: + mock_dissolution.assert_called_once() + assert business.identifier == 'FM0000002' + mock_dissolution.assert_called_with(business, 'test') + + # Other processors should not be called + mock_admin_revoke.assert_not_called() + mock_business_number.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_put_back_on.assert_not_called() + elif dc_msg['type'] == PUT_BACK_ON: + mock_put_back_on.assert_called_once() + assert business.identifier == 'FM0000003' + mock_put_back_on.assert_called_with(business) + + # Other processors should not be called + mock_admin_revoke.assert_not_called() + mock_business_number.assert_not_called() + mock_change_of_registration.assert_not_called() + mock_dissolution.assert_not_called() + else: + assert False + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dc_msg', [{ + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001' +}, { + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001', + 'data': {} +}, { + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001', + 'data': {'filing': {}} +}, { + 'type': CHANGE_OF_REGISTRATION, + 'identifier': 'FM0000001', + 'data': {'filing': {'header': {}}} +}]) +async def test_process_failure_filing_required(app, session, dc_msg): + """Assert processor throws QueueException if filing data not in message.""" + # Arrange + from entity_queue_common.service_utils import QueueException + + # Act + with pytest.raises(QueueException) as excinfo: + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + assert 'Digital credential message is missing data.' in str(excinfo) + + +@pytest.mark.asyncio +async def test_process_failure_no_identifier_no_filing_required(app, session): + """Assert processor throws QueueException if no idenfiier in message.""" + # Arrange + from entity_queue_common.service_utils import QueueException + dc_msg = {'type': ADMIN_REVOKE} + + # Act + with pytest.raises(QueueException) as excinfo: + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + assert 'Digital credential message is missing identifier' in str(excinfo) + + +@pytest.mark.asyncio +async def test_process_failure_no_business_no_filing_required(app, session): + """Assert processor throws Exception if idenfiier in message but business not found.""" + # Arrange + identifier = 'FM0000001' + dc_msg = {'type': ADMIN_REVOKE, 'identifier': identifier} + + # Act + with pytest.raises(Exception) as excinfo: + await process_digital_credential(dc_msg, flask_app=app) + + # Assert + assert f'Business with identifier: {identifier} not found.' in str(excinfo) diff --git a/queue_services/entity-emailer/flags.json b/queue_services/entity-emailer/flags.json new file mode 100644 index 0000000000..877972a91c --- /dev/null +++ b/queue_services/entity-emailer/flags.json @@ -0,0 +1,5 @@ +{ + "flagValues": { + "disable-specific-service-provider": true + } +} \ No newline at end of file diff --git a/queue_services/entity-emailer/src/entity_emailer/config.py b/queue_services/entity-emailer/src/entity_emailer/config.py index 33b406c61d..859cefe8b1 100644 --- a/queue_services/entity-emailer/src/entity_emailer/config.py +++ b/queue_services/entity-emailer/src/entity_emailer/config.py @@ -74,6 +74,8 @@ class Config: # pylint: disable=too-few-public-methods ENVIRONMENT = os.getenv("APP_ENV", "prod") + LD_SDK_KEY = os.getenv("LD_SDK_KEY", None) + SENTRY_DSN = os.getenv("SENTRY_DSN", None) SQLALCHEMY_TRACK_MODIFICATIONS = False @@ -134,6 +136,12 @@ class Config: # pylint: disable=too-few-public-methods "PUBLISHER_AUDIENCE", "https://pubsub.googleapis.com/google.pubsub.v1.Publisher" ) + NAME_REQUEST_URL = os.getenv("NAME_REQUEST_URL", "") + DECIDE_BUSINESS_URL = os.getenv("DECIDE_BUSINESS_URL", "") + COLIN_URL = os.getenv("COLIN_URL", "") + CORP_FORMS_URL = os.getenv("CORP_FORMS_URL", "") + SOCIETIES_URL = os.getenv("SOCIETIES_URL", "") + class Development(Config): # pylint: disable=too-few-public-methods """Creates the Development Config object.""" diff --git a/queue_services/entity-emailer/src/entity_emailer/email_processors/agm_extension_notification.py b/queue_services/entity-emailer/src/entity_emailer/email_processors/agm_extension_notification.py new file mode 100644 index 0000000000..46df7398a4 --- /dev/null +++ b/queue_services/entity-emailer/src/entity_emailer/email_processors/agm_extension_notification.py @@ -0,0 +1,150 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Email processing rules and actions for AGM Extension notifications.""" +from __future__ import annotations + +import base64 +import re +from http import HTTPStatus +from pathlib import Path + +import requests +from entity_queue_common.service_utils import logger +from flask import current_app +from jinja2 import Template +from legal_api.models import Filing, LegalEntity + +from entity_emailer.email_processors import get_filing_info, get_recipient_from_auth, substitute_template_parts + + +def _get_pdfs( + token: str, + business: dict, + filing: Filing, + filing_date_time: str, + effective_date: str) -> list: + # pylint: disable=too-many-locals, too-many-branches, too-many-statements, too-many-arguments + """Get the pdfs for the AGM Extension output.""" + pdfs = [] + attach_order = 1 + headers = { + "Accept": "application/pdf", + "Authorization": f"Bearer {token}" + } + + # add filing pdf + filing_pdf = requests.get( + f'{current_app.config.get("LEGAL_API_URL")}/businesses/{business["identifier"]}/filings/{filing.id}' + "?type=letterOfAgmExtension", headers=headers + ) + if filing_pdf.status_code != HTTPStatus.OK: + logger.error("Failed to get pdf for filing: %s", filing.id) + else: + filing_pdf_encoded = base64.b64encode(filing_pdf.content) + pdfs.append( + { + "fileName": "Letter of AGM Extension Approval.pdf", + "fileBytes": filing_pdf_encoded.decode("utf-8"), + "fileUrl": "", + "attachOrder": attach_order + } + ) + attach_order += 1 + + # add receipt pdf + corp_name = business.get("legalName") + business_data = LegalEntity.find_by_internal_id(filing.business_id) + receipt = requests.post( + f'{current_app.config.get("PAY_API_URL")}/{filing.payment_token}/receipts', + json={ + "corpName": corp_name, + "filingDateTime": filing_date_time, + "effectiveDateTime": effective_date if effective_date != filing_date_time else "", + "filingIdentifier": str(filing.id), + "businessNumber": business_data.tax_id if business_data and business_data.tax_id else "" + }, + headers=headers + ) + if receipt.status_code != HTTPStatus.CREATED: + logger.error("Failed to get receipt pdf for filing: %s", filing.id) + else: + receipt_encoded = base64.b64encode(receipt.content) + pdfs.append( + { + "fileName": "Receipt.pdf", + "fileBytes": receipt_encoded.decode("utf-8"), + "fileUrl": "", + "attachOrder": attach_order + } + ) + attach_order += 1 + + return pdfs + + +def process(email_info: dict, token: str) -> dict: # pylint: disable=too-many-locals, too-many-branches + """Build the email for AGM Extension notification.""" + logger.debug("agm_extension_notification: %s", email_info) + # get template and fill in parts + filing_type, status = email_info["type"], email_info["option"] + # get template vars from filing + filing, business, leg_tmz_filing_date, leg_tmz_effective_date = get_filing_info(email_info["filingId"]) + filing_name = filing.filing_type[0].upper() + " ".join(re.findall("[a-zA-Z][^A-Z]*", filing.filing_type[1:])) + + template = Path( + f'{current_app.config.get("TEMPLATE_PATH")}/AGM-EXT-{status}.html' + ).read_text() + filled_template = substitute_template_parts(template) + # render template with vars + jnja_template = Template(filled_template, autoescape=True) + filing_data = (filing.json)["filing"][f"{filing_type}"] + html_out = jnja_template.render( + business=business, + filing=filing_data, + header=(filing.json)["filing"]["header"], + filing_date_time=leg_tmz_filing_date, + effective_date_time=leg_tmz_effective_date, + entity_dashboard_url=current_app.config.get("DASHBOARD_URL") + + (filing.json)["filing"]["business"].get("identifier", ""), + email_header=filing_name.upper(), + filing_type=filing_type + ) + + # get attachments + pdfs = _get_pdfs(token, business, filing, leg_tmz_filing_date, leg_tmz_effective_date) + + # get recipients + identifier = filing.filing_json["filing"]["business"]["identifier"] + recipients = [] + recipients.append(get_recipient_from_auth(identifier, token)) + + recipients = list(set(recipients)) + recipients = ", ".join(filter(None, recipients)).strip() + + # assign subject + subject = "AGM Extension Documents from the Business Registry" + + legal_name = business.get("legalName", None) + legal_name = "Numbered Company" if legal_name.startswith(identifier) else legal_name + subject = f"{legal_name} - {subject}" if legal_name else subject + + return { + "recipients": recipients, + "requestBy": "BCRegistries@gov.bc.ca", + "content": { + "subject": subject, + "body": f"{html_out}", + "attachments": pdfs + } + } diff --git a/queue_services/entity-emailer/src/entity_emailer/email_processors/agm_location_change_notification.py b/queue_services/entity-emailer/src/entity_emailer/email_processors/agm_location_change_notification.py new file mode 100644 index 0000000000..e205870fe6 --- /dev/null +++ b/queue_services/entity-emailer/src/entity_emailer/email_processors/agm_location_change_notification.py @@ -0,0 +1,150 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Email processing rules and actions for AGM Location Change notifications.""" +from __future__ import annotations + +import base64 +import re +from http import HTTPStatus +from pathlib import Path + +import requests +from entity_queue_common.service_utils import logger +from flask import current_app +from jinja2 import Template +from legal_api.models import Filing, LegalEntity + +from entity_emailer.email_processors import get_filing_info, get_recipient_from_auth, substitute_template_parts + + +def _get_pdfs( + token: str, + business: dict, + filing: Filing, + filing_date_time: str, + effective_date: str) -> list: + # pylint: disable=too-many-locals, too-many-branches, too-many-statements, too-many-arguments + """Get the pdfs for the AGM Location Change output.""" + pdfs = [] + attach_order = 1 + headers = { + "Accept": "application/pdf", + "Authorization": f"Bearer {token}" + } + + # add filing pdf + filing_pdf = requests.get( + f'{current_app.config.get("LEGAL_API_URL")}/businesses/{business["identifier"]}/filings/{filing.id}' + "?type=letterOfAgmLocationChange", headers=headers + ) + if filing_pdf.status_code != HTTPStatus.OK: + logger.error("Failed to get pdf for filing: %s", filing.id) + else: + filing_pdf_encoded = base64.b64encode(filing_pdf.content) + pdfs.append( + { + "fileName": "Letter of AGM Location Change Approval.pdf", + "fileBytes": filing_pdf_encoded.decode("utf-8"), + "fileUrl": "", + "attachOrder": attach_order + } + ) + attach_order += 1 + + # add receipt pdf + corp_name = business.get("legalName") + business_data = LegalEntity.find_by_internal_id(filing.business_id) + receipt = requests.post( + f'{current_app.config.get("PAY_API_URL")}/{filing.payment_token}/receipts', + json={ + "corpName": corp_name, + "filingDateTime": filing_date_time, + "effectiveDateTime": effective_date if effective_date != filing_date_time else "", + "filingIdentifier": str(filing.id), + "businessNumber": business_data.tax_id if business_data and business_data.tax_id else "" + }, + headers=headers + ) + if receipt.status_code != HTTPStatus.CREATED: + logger.error("Failed to get receipt pdf for filing: %s", filing.id) + else: + receipt_encoded = base64.b64encode(receipt.content) + pdfs.append( + { + "fileName": "Receipt.pdf", + "fileBytes": receipt_encoded.decode("utf-8"), + "fileUrl": "", + "attachOrder": attach_order + } + ) + attach_order += 1 + + return pdfs + + +def process(email_info: dict, token: str) -> dict: # pylint: disable=too-many-locals, too-many-branches + """Build the email for AGM Location Change notification.""" + logger.debug("agm_location_change_notification: %s", email_info) + # get template and fill in parts + filing_type, status = email_info["type"], email_info["option"] + # get template vars from filing + filing, business, leg_tmz_filing_date, leg_tmz_effective_date = get_filing_info(email_info["filingId"]) + filing_name = filing.filing_type[0].upper() + " ".join(re.findall("[a-zA-Z][^A-Z]*", filing.filing_type[1:])) + + template = Path( + f'{current_app.config.get("TEMPLATE_PATH")}/AGM-LOCCHG-{status}.html' + ).read_text() + filled_template = substitute_template_parts(template) + # render template with vars + jnja_template = Template(filled_template, autoescape=True) + filing_data = (filing.json)["filing"][f"{filing_type}"] + html_out = jnja_template.render( + business=business, + filing=filing_data, + header=(filing.json)["filing"]["header"], + filing_date_time=leg_tmz_filing_date, + effective_date_time=leg_tmz_effective_date, + entity_dashboard_url=current_app.config.get("DASHBOARD_URL") + + (filing.json)["filing"]["business"].get("identifier", ""), + email_header=filing_name.upper(), + filing_type=filing_type + ) + + # get attachments + pdfs = _get_pdfs(token, business, filing, leg_tmz_filing_date, leg_tmz_effective_date) + + # get recipients + identifier = filing.filing_json["filing"]["business"]["identifier"] + recipients = [] + recipients.append(get_recipient_from_auth(identifier, token)) + + recipients = list(set(recipients)) + recipients = ", ".join(filter(None, recipients)).strip() + + # assign subject + subject = "AGM Location Change Documents from the Business Registry" + + legal_name = business.get("legalName", None) + legal_name = "Numbered Company" if legal_name.startswith(identifier) else legal_name + subject = f"{legal_name} - {subject}" if legal_name else subject + + return { + "recipients": recipients, + "requestBy": "BCRegistries@gov.bc.ca", + "content": { + "subject": subject, + "body": f"{html_out}", + "attachments": pdfs + } + } diff --git a/queue_services/entity-emailer/src/entity_emailer/email_processors/ar_reminder_notification.py b/queue_services/entity-emailer/src/entity_emailer/email_processors/ar_reminder_notification.py index acae21115d..f49f796df2 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_processors/ar_reminder_notification.py +++ b/queue_services/entity-emailer/src/entity_emailer/email_processors/ar_reminder_notification.py @@ -28,7 +28,7 @@ ) -def process(email_msg: dict, token: str) -> dict: +def process(email_msg: dict, token: str, flag_on: bool) -> dict: """Build the email for annual report reminder notification.""" structured_log(request, "DEBUG", f"ar_reminder_notification: {email_msg}") ar_fee = email_msg["arFee"] @@ -48,8 +48,8 @@ def process(email_msg: dict, token: str) -> dict: ar_fee=ar_fee, ar_year=ar_year, entity_type=corp_type.full_desc, - entity_dashboard_url=current_app.config.get("DASHBOARD_URL") - + business.identifier, + entity_dashboard_url=current_app.config.get("DASHBOARD_URL") + business.identifier, + disable_specific_service_provider=flag_on ) # get recipients diff --git a/queue_services/entity-emailer/src/entity_emailer/email_processors/correction_notification.py b/queue_services/entity-emailer/src/entity_emailer/email_processors/correction_notification.py index 45b26cefd8..0f75369f06 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_processors/correction_notification.py +++ b/queue_services/entity-emailer/src/entity_emailer/email_processors/correction_notification.py @@ -24,7 +24,7 @@ from flask import current_app from flask import request from jinja2 import Template -from legal_api.core.filing_helper import is_special_resolution_correction +from legal_api.core.filing_helper import is_special_resolution_correction_by_filing_json from legal_api.models import Filing from entity_emailer.services.logging import structured_log @@ -47,9 +47,8 @@ def _get_pdfs( attach_order = 1 headers = {"Accept": "application/pdf", "Authorization": f"Bearer {token}"} entity_type = business.get("legalType", None) - is_cp_special_resolution = is_special_resolution_correction( - filing.filing_json["filing"], business, filing - ) + is_cp_special_resolution = entity_type == 'CP' and is_special_resolution_correction_by_filing_json( + filing.filing_json['filing']) if status == Filing.Status.PAID.value: # add filing pdf @@ -66,9 +65,7 @@ def _get_pdfs( filing_pdf_encoded = base64.b64encode(filing_pdf.content) pdfs.append( { - "fileName": "Special Resolution Correction Application.pdf" - if is_cp_special_resolution - else "Register Correction Application.pdf", + "fileName": "Register Correction Application.pdf", "fileBytes": filing_pdf_encoded.decode("utf-8"), "fileUrl": "", "attachOrder": attach_order, @@ -153,12 +150,10 @@ def _get_pdfs( ) attach_order += 1 elif is_cp_special_resolution: - rules_changed = bool( - filing.filing_json["filing"]["correction"].get("rulesFileKey") - ) - pdfs = get_completed_pdfs( - token, business, filing, name_changed, rules_changed - ) + rules_changed = bool(filing.filing_json["filing"]["correction"].get("rulesFileKey")) + memorandum_changed = bool(filing.filing_json["filing"]["correction"].get("memorandumFileKey")) + pdfs = get_completed_pdfs(token, business, filing, name_changed, + rules_changed=rules_changed, memorandum_changed=memorandum_changed) return pdfs @@ -266,11 +261,11 @@ def process( "changeOfDirectors", ]: return None - elif is_special_resolution_correction( - filing.filing_json["filing"], business, filing + elif entity_type == "CP" and is_special_resolution_correction_by_filing_json( + filing.filing_json["filing"] ): prefix = "CP-SR" - name_changed = filing.filing_json["filing"]["correction"].get("nameRequest", {}) + name_changed = "requestType" in filing.filing_json["filing"]["correction"].get("nameRequest", {}) else: return None diff --git a/queue_services/entity-emailer/src/entity_emailer/email_processors/nr_notification.py b/queue_services/entity-emailer/src/entity_emailer/email_processors/nr_notification.py index 698e159231..789c8b46aa 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_processors/nr_notification.py +++ b/queue_services/entity-emailer/src/entity_emailer/email_processors/nr_notification.py @@ -39,6 +39,31 @@ class Option(Enum): REFUND = "refund" +def __is_modernized(legal_type): + modernized_list = ["GP", "DBA", "FR", "CP", "BC"] + return legal_type in modernized_list + + +def __is_colin(legal_type): + colin_list = ["CR", "UL", "CC", "XCR", "XUL", "RLC"] + return legal_type in colin_list + + +def _is_society(legal_type): + society_list = ["SO", "XSO"] + return legal_type in society_list + + +def __get_instruction_group(legal_type): + if __is_modernized(legal_type): + return "modernized" + if __is_colin(legal_type): + return "colin" + if _is_society(legal_type): + return "so" + return "" + + def process(email_info: dict, option) -> dict: # pylint: disable-msg=too-many-locals """ Build the email for Name Request notification. @@ -47,10 +72,6 @@ def process(email_info: dict, option) -> dict: # pylint: disable-msg=too-many-l """ structured_log(request, "DEBUG", f"NR {option} notification: {email_info}") nr_number = email_info["identifier"] - template = Path( - f'{current_app.config.get("TEMPLATE_PATH")}/NR-{option.upper()}.html' - ).read_text() - filled_template = substitute_template_parts(template) nr_response = NameXService.query_nr_number(nr_number) if nr_response.status_code != HTTPStatus.OK: @@ -79,13 +100,36 @@ def process(email_info: dict, option) -> dict: # pylint: disable-msg=too-many-l business_name = n_item["name"] break + name_request_url = current_app.config.get("NAME_REQUEST_URL") + decide_business_url = current_app.config.get("DECIDE_BUSINESS_URL") + corp_online_url = current_app.config.get("COLIN_URL") + form_page_url = current_app.config.get("CORP_FORMS_URL") + societies_url = current_app.config.get("SOCIETIES_URL") + + file_name_suffix = option.upper() + if option == Option.BEFORE_EXPIRY.value: + if "entity_type_cd" in nr_data: + legal_type = nr_data["entity_type_cd"] + group = __get_instruction_group(legal_type) + if group: + instruction_group = "-" + group + file_name_suffix += instruction_group.upper() + + template = Path(f'{current_app.config.get("TEMPLATE_PATH")}/NR-{file_name_suffix}.html').read_text() + filled_template = substitute_template_parts(template) + # render template with vars mail_template = Template(filled_template, autoescape=True) html_out = mail_template.render( nr_number=nr_number, expiration_date=expiration_date, - business_name=business_name, + legal_name=business_name, refund_value=refund_value, + name_request_url=name_request_url, + decide_business_url=decide_business_url, + corp_online_url=corp_online_url, + form_page_url=form_page_url, + societies_url=societies_url ) # get recipients diff --git a/queue_services/entity-emailer/src/entity_emailer/email_processors/special_resolution_helper.py b/queue_services/entity-emailer/src/entity_emailer/email_processors/special_resolution_helper.py index 9b3e93dd7b..86a527ee77 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_processors/special_resolution_helper.py +++ b/queue_services/entity-emailer/src/entity_emailer/email_processors/special_resolution_helper.py @@ -24,7 +24,7 @@ def get_completed_pdfs( - token: str, business: dict, filing: Filing, name_changed: bool, rules_changed=False + token: str, business: dict, filing: Filing, name_changed: bool, rules_changed=False, memorandum_changed=False ) -> list: # pylint: disable=too-many-locals, too-many-branches, too-many-statements, too-many-arguments """Get the completed pdfs for the special resolution output.""" @@ -67,7 +67,8 @@ def get_completed_pdfs( ) if name_change.status_code == HTTPStatus.OK: - certified_name_change_encoded = base64.b64encode(name_change.content) + certified_name_change_encoded = base64.b64encode( + name_change.content) pdfs.append( { "fileName": "Certificate of Name Change.pdf", @@ -103,6 +104,25 @@ def get_completed_pdfs( ) attach_order += 1 + # Certified Memorandum + if memorandum_changed: + memorandum = requests.get( + f'{current_app.config.get("LEGAL_API_URL")}/businesses/{business["identifier"]}/filings/{filing.id}' + "?type=certifiedMemorandum", + headers=headers + ) + if memorandum.status_code == HTTPStatus.OK: + certified_memorandum_encoded = base64.b64encode(memorandum.content) + pdfs.append( + { + "fileName": "Certified Memorandum.pdf", + "fileBytes": certified_memorandum_encoded.decode("utf-8"), + "fileUrl": "", + "attachOrder": attach_order + } + ) + attach_order += 1 + return pdfs @@ -129,7 +149,8 @@ def get_paid_pdfs( ) if sr_filing_pdf.status_code != HTTPStatus.OK: - structured_log(request, "ERROR", f"Failed to get pdf for filing: {filing.id}") + structured_log(request, "ERROR", + f"Failed to get pdf for filing: {filing.id}") else: sr_filing_pdf_encoded = base64.b64encode(sr_filing_pdf.content) pdfs.append( diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/AGM-EXT-COMPLETED.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/AGM-EXT-COMPLETED.html new file mode 100644 index 0000000000..67285d9cf0 --- /dev/null +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/AGM-EXT-COMPLETED.html @@ -0,0 +1,56 @@ + + + + + + + + + Confirmation of AGM Extension + [[style.html]] + + + + + + + + + + diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/AGM-LOCCHG-COMPLETED.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/AGM-LOCCHG-COMPLETED.html new file mode 100644 index 0000000000..aa4b1ddb67 --- /dev/null +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/AGM-LOCCHG-COMPLETED.html @@ -0,0 +1,56 @@ + + + + + + + + + Confirmation of AGM Location Change + [[style.html]] + + + + + + + + + + diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/AR-REMINDER.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/AR-REMINDER.html index 5405cfa22e..afd1ca01e9 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_templates/AR-REMINDER.html +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/AR-REMINDER.html @@ -29,12 +29,29 @@ Annual Report. The filing fee for the annual report is ${{ ar_fee }} + $1.50 service fee.

+ {% if disable_specific_service_provider %} + [[whitespace-16px.html]] +

+ If you prefer to file by mail, contact an accountant, lawyer or service provider of your choice for support. + An additional fee may apply. +

+ [[whitespace-16px.html]] +

+ For additional support with BC Registries filings, you may visit a Service BC location or call + 1-877-370-1033. Visit bcregistry.gov.bc.ca/filing for your service options. +

+ [[whitespace-16px.html]] +

+ Please note that Service BC does not provide legal or financial advice. +

+ {% else %} [[whitespace-16px.html]]

If you prefer to file by mail, contact the Corporate Registry's preferred service provider, Dye & Durham, who can file on your behalf for an additional fee. For information, contact Dye and Durham at 1-800-268-7580 or visit their website at www.dyedurhambc.com

+ {% endif %} [[whitespace-16px.html]]

diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/CP-SR-CRCTN-COMPLETED.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/CP-SR-CRCTN-COMPLETED.html index f4d3b95c3d..992778e09d 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_templates/CP-SR-CRCTN-COMPLETED.html +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/CP-SR-CRCTN-COMPLETED.html @@ -19,7 +19,7 @@ [[header.html]]

-

You have successfully filed a correction with the BC Business Registry.

+

The correction filing for {{business.legalName}} is now effective.

[[business-info.html]]

The following documents are attached to this email:

@@ -31,6 +31,9 @@ {% if filing['rulesFileKey'] %}
  • Certified Rules
  • {% endif %} + {% if filing['memorandumFileKey'] %} +
  • Certified Memorandum
  • + {% endif %} [[business-dashboard-link.html]] diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/CP-SR-CRCTN-PAID.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/CP-SR-CRCTN-PAID.html index a6afad79ad..fe29e7bf5a 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_templates/CP-SR-CRCTN-PAID.html +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/CP-SR-CRCTN-PAID.html @@ -19,7 +19,6 @@ [[header.html]]
    -

    You have successfully filed a correction with the BC Business Registry.

    [[business-info.html]]

    The following documents are attached to this email:

    diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-COLIN.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-COLIN.html new file mode 100644 index 0000000000..49aa685362 --- /dev/null +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-COLIN.html @@ -0,0 +1,48 @@ +# Your Name Request is Expiring Soon + +Your name request {{ nr_number }} for **{{ legal_name }}** is due to expire on {{ expiration_date }}. + +If your name request expires: + +* The approved name is no longer reserved for completing an application and becomes available to the public +* You will need to submit and pay for a new name request + +Finish completing an application with this name before it expires. If you are not ready to completing an application yet, you can renew this name request to extend the expiry date. + +--- + +# Option 1: Finish completing an application + +Follow these steps to completing an application using the approved name: + +1. Visit [BC Corporate Online]( {{ corp_online_url }}) +2. complete application with this name by filing an Incorporation Application + +If you don't have a BC Registries Account, [create one here]({{ decide_business_url }}) + +--- + +# Option 2: Renew your Name Request + +You may renew your name request to extend the expiry date by 56 days from the original date of expiry: + +1. Visit [BC Registries and Online Services]({{ name_request_url }}) +2. Click on the "Manage My Name Request" tab +3. Enter the NR number, and applicant's phone number or email address +4. Click the "Retrieve Name Request" button +5. Click "Renew Name Request ($30)" +6. Make a Payment + +--- + +# Your Name Request Details + +**Name Request Number:** +{{ nr_number }} + +**Name Request Expiry Date and Time:** +{{ expiration_date }} + +--- + +[[nr-footer.html]] \ No newline at end of file diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-MODERNIZED.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-MODERNIZED.html new file mode 100644 index 0000000000..36c2ee3486 --- /dev/null +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-MODERNIZED.html @@ -0,0 +1,50 @@ +# Your Name Request is Expiring Soon + +Your name request {{ nr_number }} for **{{ legal_name }}** is due to expire on {{ expiration_date }}. + +If your name request expires: + +* The approved name is no longer reserved for completing an application and becomes available to the public +* You will need to submit and pay for a new name request + +Finish completing an application with this name before it expires. If you are not ready to complete an application yet, you can renew this name request to extend the expiry date. + +--- + +# Option 1: Finish completing an application + +Follow these steps to completing an application using the approved name: + +1. Visit [BC Registries and Online Services]({{ name_request_url }}) +2. Log in with your BC Registries Account +3. Look up your Name Request +4. completing an application with this name by following the instructions + +If you don't have a BC Registries Account, [create one here]({{ decide_business_url }}) + +--- + +# Option 2: Renew your Name Request + +You may renew your name request to extend the expiry date by 56 days from the original date of expiry: + +1. Visit [BC Registries and Online Services]({{ name_request_url }}) +2. Click on the "Manage My Name Request" tab +3. Enter the NR number, and applicant's phone number or email address +4. Click the "Retrieve Name Request" button +5. Click "Renew Name Request ($30)" +6. Make a Payment + +--- + +# Your Name Request Details + +**Name Request Number:** +{{ nr_number }} + +**Name Request Expiry Date and Time:** +{{ expiration_date }} + +--- + +[[nr-footer.html]] \ No newline at end of file diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-SO.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-SO.html new file mode 100644 index 0000000000..72c1d99f2d --- /dev/null +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY-SO.html @@ -0,0 +1,44 @@ +# Your Name Request is Expiring Soon + +Your name request {{ nr_number }} for **{{ legal_name }}** is due to expire on {{ expiration_date }}. + +If your name request expires: + +* The approved name is no longer reserved for business completion and becomes available to the public +* You will need to submit and pay for a new name request + +Finish completing an application with this name before it expires. If you are not ready to completing an application yet, you can renew this name request to extend the expiry date. + +--- + +# Option 1: Finish completing an application + +Follow these steps to completing your application using the approved name: + +1. Visit {{ societies_url }} +2. Log in with your BCeID +3. Complete your Society application with the approved Name Request Number + +--- + +# Option 2: Renew your Name Request + +You may renew your name request to extend the expiry date by 56 days from the original date of expiry: + +1. Visit {{ societies_url }} +2. Log in with your BCeID +3. Request a new name + +--- + +# Your Name Request Details + +**Name Request Number:** +{{ nr_number }} + +**Name Request Expiry Date and Time:** +{{ expiration_date }} + +--- + +[[nr-footer.html]] \ No newline at end of file diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY.html index 4daa71fcbb..a0f7a332ff 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY.html +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-BEFORE-EXPIRY.html @@ -1,21 +1,49 @@ -Your name request {{ nr_number }} for the approved name **{{ legal_name }}** is due to expire on {{ expiration_date }}. +# Your Name Request is Expiring Soon -If your name request expires, you will need to pay for another name request, and wait for it to be reviewed. +Your name request {{ nr_number }} for **{{ legal_name }}** is due to expire on {{ expiration_date }}. -As well, if your name request expires, the approved name becomes available to the public. +If your name request expires: -We recommend that you use your name request before it expires, or go to [www.bcregistry.ca/namerequest](www.bcregistry.ca/namerequest) and renew your name request. +* The approved name is no longer reserved for business registration and becomes available to the public +* You will need to submit and pay for a new name request -To renew your name request: +Complete your application with this name before it expires. If you are not ready to complete the application yet, you can renew this name request to extend the expiry date. -* Go to [www.bcregistry.ca/namerequest](www.bcregistry.ca/namerequest) -* Go to the "Manage My Name Request" tab -* Enter the NR number ({{ nr_number }}), and applicant's phone number or email address -* Click the "Retrieve Name Request" button -* Click "Renew Name Request ($30)" -* Pay +--- -Name requests are renewed for an additional 56 days from the original expiry date, with the exception of restoration or reinstatements, which are renewed for 1 year and 56 days. +# Option 1: Complete your application +Follow these steps to complete your application using the approved name: -[[nr-footer.html]] \ No newline at end of file +1.Visit [Forms, fees and information packages page]({{ form_page_url }}) +2. Download the appropriate form +3. Complete and submit the form along with any required documentation and payment + +If you don't have a BC Registries Account, [create one here]({{ decide_business_url }}) + +--- + +# Option 2: Renew your Name Request + +You may renew your name request to extend the expiry date by 56 days from the original date of expiry: + +1. Visit [BC Registries and Online Services]({{ name_request_url }}) +2. Click on the "Manage My Name Request" tab +3. Enter the NR number, and applicant's phone number or email address +4. Click the "Retrieve Name Request" button +5. Click "Renew Name Request ($30)" +6. Make a Payment + +--- + +# Your Name Request Details + +**Name Request Number:** +{{ nr_number }} + +**Name Request Expiry Date and Time:** +{{ expiration_date }} + +--- + +[[nr-footer.html]] diff --git a/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-EXPIRED.html b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-EXPIRED.html index 4fe18cc5f3..ddd3eea01b 100644 --- a/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-EXPIRED.html +++ b/queue_services/entity-emailer/src/entity_emailer/email_templates/NR-EXPIRED.html @@ -1,8 +1,17 @@ -Your BC Registries Name Request number {{ nr_number }} has expired, and the approved name **{{ legal_name }}** is no longer reserved for your use. +# Your Name Request has Expired -If you still wish to use this name, please go to [www.bcregistry.ca/namerequest](www.bcregistry.ca/namerequest) to look up {{ nr_number }} under the Manage My Name Request tab and click the Resubmit button to submit a new Name Request for this name. +Your Name Request ({{ nr_number }}) has expired and the approved name **{{ legal_name }}** is no longer reserved for your use -Note: Name availability is not guaranteed until it has been reviewed by staff. +--- +# Your Name Request Details + +**Name Request Number:** +{{ nr_number }} + +**Name Request Expiry Date and Time:** +{{ expiration_date }} + +--- [[nr-footer.html]] diff --git a/queue_services/entity-emailer/src/entity_emailer/resources/worker.py b/queue_services/entity-emailer/src/entity_emailer/resources/worker.py index 646ce26b28..9c20a42303 100644 --- a/queue_services/entity-emailer/src/entity_emailer/resources/worker.py +++ b/queue_services/entity-emailer/src/entity_emailer/resources/worker.py @@ -45,12 +45,15 @@ from legal_api import db from legal_api.models import Filing from legal_api.services.bootstrap import AccountService +from legal_api.services.flags import Flags from simple_cloudevent import SimpleCloudEvent from entity_emailer.services import queue from entity_emailer.services.logging import structured_log from entity_emailer.email_processors import ( affiliation_notification, + agm_extension_notification, + agm_location_change_notification, ar_reminder_notification, bn_notification, change_of_registration_notification, @@ -129,7 +132,8 @@ def worker(): if not email["recipients"] or not email["content"] or not email["content"]["body"]: # email object(s) is missing, take off queue - structured_log(request, "INFO", "Send email: email object(s) is missing") + structured_log(request, "INFO", + "Send email: email object(s) is missing") return {}, HTTPStatus.OK resp = send_email(email, token) @@ -152,10 +156,16 @@ def process_email( email_msg: dict, token: str ): # pylint: disable=too-many-branches, too-many-statements """Process the email contained in the submission.""" - structured_log(request, "DEBUG", f"Attempting to process email: {email_msg}") + flags = Flags() + if current_app.config.get("LD_SDK_KEY", None): + flags.init_app(current_app) + + structured_log(request, "DEBUG", + f"Attempting to process email: {email_msg}") etype = email_msg.get("type", None) if etype and etype == "bc.registry.names.request": - option = email_msg.get("data", {}).get("request", {}).get("option", None) + option = email_msg.get("data", {}).get( + "request", {}).get("option", None) if option and option in [ nr_notification.Option.BEFORE_EXPIRY.value, nr_notification.Option.EXPIRED.value, @@ -178,11 +188,20 @@ def process_email( elif etype == "incorporationApplication" and option == "mras": email = mras_notification.process(email_msg["email"]) elif etype == "annualReport" and option == "reminder": - email = ar_reminder_notification.process(email_msg["email"], token) + flag_on = flags.is_on("disable-specific-service-provider") + email = ar_reminder_notification.process( + email_msg["email"], token, flag_on) + elif etype == "agmLocationChange" and option == Filing.Status.COMPLETED.value: + email = agm_location_change_notification.process( + email_msg["email"], token) + elif etype == "agmExtension" and option == Filing.Status.COMPLETED.value: + email = agm_extension_notification.process( + email_msg["email"], token) elif etype == "dissolution": email = dissolution_notification.process(email_msg["email"], token) elif etype == "registration": - email = registration_notification.process(email_msg["email"], token) + email = registration_notification.process( + email_msg["email"], token) elif etype == "restoration": email = restoration_notification.process(email_msg["email"], token) elif etype == "changeOfRegistration": @@ -196,9 +215,11 @@ def process_email( email_msg["email"], token ) elif etype == "continuationOut": - email = continuation_out_notification.process(email_msg["email"], token) + email = continuation_out_notification.process( + email_msg["email"], token) elif etype == "specialResolution": - email = special_resolution_notification.process(email_msg["email"], token) + email = special_resolution_notification.process( + email_msg["email"], token) elif etype in filing_notification.FILING_TYPE_CONVERTER.keys(): if etype == "annualReport" and option == Filing.Status.COMPLETED.value: return None diff --git a/queue_services/entity-emailer/tests/unit/__init__.py b/queue_services/entity-emailer/tests/unit/__init__.py index 840183414d..f4571169de 100644 --- a/queue_services/entity-emailer/tests/unit/__init__.py +++ b/queue_services/entity-emailer/tests/unit/__init__.py @@ -41,6 +41,8 @@ from legal_api.models import LegalEntity, Filing, RegistrationBootstrap, User from registry_schemas.example_data import ( + AGM_EXTENSION, + AGM_LOCATION_CHANGE, ALTERATION, ALTERATION_FILING_TEMPLATE, ANNUAL_REPORT, @@ -114,9 +116,11 @@ def create_filing(token=None, filing_json=None, legal_entity_id=None, filing_dat def prep_incorp_filing(session, identifier, payment_id, option, legal_type=None): """Return a new incorp filing prepped for email notification.""" - legal_entity = create_legal_entity(identifier, legal_type=legal_type, legal_name=LEGAL_NAME) + legal_entity = create_legal_entity( + identifier, legal_type=legal_type, legal_name=LEGAL_NAME) filing_template = copy.deepcopy(INCORPORATION_FILING_TEMPLATE) - filing_template['filing']['business'] = {'identifier': legal_entity.identifier} + filing_template['filing']['business'] = { + 'identifier': legal_entity.identifier} if legal_entity.legal_type: filing_template['filing']['business']['legalType'] = legal_entity.legal_type filing_template['filing']['incorporationApplication']['nameRequest']['legalType'] = legal_entity.legal_type @@ -197,7 +201,8 @@ def prep_registration_filing(session, identifier, payment_id, option, legal_type 'foundingDate': legal_entity.founding_date.isoformat() } - filing = create_filing(token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity_id) + filing = create_filing( + token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity_id) filing.payment_completion_date = filing.filing_date filing.save() if option in ['COMPLETED']: @@ -214,7 +219,8 @@ def prep_dissolution_filing(session, identifier, payment_id, option, legal_type, filing_template = copy.deepcopy(FILING_HEADER) filing_template['filing']['header']['name'] = 'dissolution' if submitter_role: - filing_template['filing']['header']['documentOptionalEmail'] = f'{submitter_role}@email.com' + filing_template['filing']['header'][ + 'documentOptionalEmail'] = f'{submitter_role}@email.com' filing_template['filing']['dissolution'] = copy.deepcopy(DISSOLUTION) filing_template['filing']['business'] = { @@ -251,9 +257,11 @@ def prep_consent_continuation_out_filing(session, identifier, payment_id, legal_ filing_template = copy.deepcopy(FILING_HEADER) filing_template['filing']['header']['name'] = 'consentContinuationOut' if submitter_role: - filing_template['filing']['header']['documentOptionalEmail'] = f'{submitter_role}@email.com' + filing_template['filing']['header'][ + 'documentOptionalEmail'] = f'{submitter_role}@email.com' - filing_template['filing']['consentContinuationOut'] = copy.deepcopy(CONSENT_CONTINUATION_OUT) + filing_template['filing']['consentContinuationOut'] = copy.deepcopy( + CONSENT_CONTINUATION_OUT) filing_template['filing']['business'] = { 'identifier': legal_entity.identifier, 'legalType': legal_type, @@ -281,9 +289,11 @@ def prep_continuation_out_filing(session, identifier, payment_id, legal_type, le filing_template = copy.deepcopy(FILING_HEADER) filing_template['filing']['header']['name'] = 'continuationOut' if submitter_role: - filing_template['filing']['header']['documentOptionalEmail'] = f'{submitter_role}@email.com' + filing_template['filing']['header'][ + 'documentOptionalEmail'] = f'{submitter_role}@email.com' - filing_template['filing']['continuationOut'] = copy.deepcopy(CONTINUATION_OUT) + filing_template['filing']['continuationOut'] = copy.deepcopy( + CONTINUATION_OUT) filing_template['filing']['business'] = { 'identifier': legal_entity.identifier, 'legalType': legal_type, @@ -345,12 +355,14 @@ def prep_change_of_registration_filing(session, identifier, payment_id, legal_ty gp_change_of_registration = copy.deepcopy(FILING_HEADER) gp_change_of_registration['filing']['header']['name'] = 'changeOfRegistration' - gp_change_of_registration['filing']['changeOfRegistration'] = copy.deepcopy(CHANGE_OF_REGISTRATION) + gp_change_of_registration['filing']['changeOfRegistration'] = copy.deepcopy( + CHANGE_OF_REGISTRATION) gp_change_of_registration['filing']['changeOfRegistration']['parties'][0]['officer']['email'] = 'party@email.com' sp_change_of_registration = copy.deepcopy(FILING_HEADER) sp_change_of_registration['filing']['header']['name'] = 'changeOfRegistration' - sp_change_of_registration['filing']['changeOfRegistration'] = copy.deepcopy(CHANGE_OF_REGISTRATION) + sp_change_of_registration['filing']['changeOfRegistration'] = copy.deepcopy( + CHANGE_OF_REGISTRATION) sp_change_of_registration['filing']['changeOfRegistration']['parties'][0]['roles'] = [ { 'roleType': 'Completing Party', @@ -376,7 +388,8 @@ def prep_change_of_registration_filing(session, identifier, payment_id, legal_ty 'legalName': legal_name } if submitter_role: - filing_template['filing']['header']['documentOptionalEmail'] = f'{submitter_role}@email.com' + filing_template['filing']['header'][ + 'documentOptionalEmail'] = f'{submitter_role}@email.com' filing = create_filing( token=payment_id, @@ -395,28 +408,89 @@ def prep_change_of_registration_filing(session, identifier, payment_id, legal_ty def prep_alteration_filing(session, identifier, option, company_name): """Return an alteration filing prepped for email notification.""" - legal_entity = create_legal_entity(identifier, legal_type=LegalEntity.EntityTypes.BCOMP.value, legal_name=company_name) + legal_entity = create_legal_entity( + identifier, legal_type=LegalEntity.EntityTypes.BCOMP.value, legal_name=company_name) filing_template = copy.deepcopy(ALTERATION_FILING_TEMPLATE) filing_template['filing']['business'] = \ - {'identifier': f'{identifier}', 'legalype': LegalEntity.EntityTypes.BCOMP.value, 'legalName': company_name} - filing = create_filing(filing_json=filing_template, legal_entity_id=legal_entity.id) + {'identifier': f'{identifier}', + 'legalype': LegalEntity.EntityTypes.BCOMP.value, 'legalName': company_name} + filing = create_filing(filing_json=filing_template, + legal_entity_id=legal_entity.id) + filing.save() + + return filing + + +def prep_agm_location_change_filing(identifier, payment_id, legal_type, legal_name): + """Return a new AGM location change filing prepped for email notification.""" + business = create_legal_entity(identifier, legal_type, legal_name) + filing_template = copy.deepcopy(FILING_HEADER) + filing_template['filing']['header']['name'] = 'agmLocationChange' + + filing_template['filing']['agmLocationChange'] = copy.deepcopy( + AGM_LOCATION_CHANGE) + filing_template['filing']['business'] = { + 'identifier': business.identifier, + 'legalType': legal_type, + 'legalName': legal_name + } + + filing = create_filing( + token=payment_id, + filing_json=filing_template, + legal_entity_id=business.id) + filing.payment_completion_date = filing.filing_date + + user = create_user('test_user') + filing.submitter_id = user.id + filing.save() + return filing + + +def prep_agm_extension_filing(identifier, payment_id, legal_type, legal_name): + """Return a new AGM extension filing prepped for email notification.""" + business = create_legal_entity(identifier, legal_type, legal_name) + filing_template = copy.deepcopy(FILING_HEADER) + filing_template['filing']['header']['name'] = 'agmExtension' + + filing_template['filing']['agmExtension'] = copy.deepcopy(AGM_EXTENSION) + filing_template['filing']['business'] = { + 'identifier': business.identifier, + 'legalType': legal_type, + 'legalName': legal_name + } + + filing = create_filing( + token=payment_id, + filing_json=filing_template, + legal_entity_id=business.id) + filing.payment_completion_date = filing.filing_date + user = create_user('test_user') + filing.submitter_id = user.id + + filing.save() return filing def prep_maintenance_filing(session, identifier, payment_id, status, filing_type, submitter_role=None): """Return a new maintenance filing prepped for email notification.""" - legal_entity = create_legal_entity(identifier, LegalEntity.EntityTypes.BCOMP.value, LEGAL_NAME) + legal_entity = create_legal_entity( + identifier, LegalEntity.EntityTypes.BCOMP.value, LEGAL_NAME) filing_template = copy.deepcopy(FILING_TEMPLATE) filing_template['filing']['header']['name'] = filing_type filing_template['filing']['business'] = \ - {'identifier': f'{identifier}', 'legalype': LegalEntity.EntityTypes.BCOMP.value, 'legalName': LEGAL_NAME} - filing_template['filing'][filing_type] = copy.deepcopy(FILING_TYPE_MAPPER[filing_type]) + {'identifier': f'{identifier}', + 'legalype': LegalEntity.EntityTypes.BCOMP.value, 'legalName': LEGAL_NAME} + filing_template['filing'][filing_type] = copy.deepcopy( + FILING_TYPE_MAPPER[filing_type]) if submitter_role: - filing_template['filing']['header']['documentOptionalEmail'] = f'{submitter_role}@email.com' - filing = create_filing(token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity.id) + filing_template['filing']['header'][ + 'documentOptionalEmail'] = f'{submitter_role}@email.com' + filing = create_filing( + token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity.id) user = create_user('test_user') filing.submitter_id = user.id @@ -436,14 +510,16 @@ def prep_maintenance_filing(session, identifier, payment_id, status, filing_type def prep_incorporation_correction_filing(session, legal_entity, original_filing_id, payment_id, option): """Return a new incorporation correction filing prepped for email notification.""" filing_template = copy.deepcopy(CORRECTION_INCORPORATION) - filing_template['filing']['business'] = {'identifier': legal_entity.identifier} + filing_template['filing']['business'] = { + 'identifier': legal_entity.identifier} for party in filing_template['filing']['correction']['parties']: for role in party['roles']: if role['roleType'] == 'Completing Party': party['officer']['email'] = 'comp_party@email.com' filing_template['filing']['correction']['contactPoint']['email'] = 'test@test.com' filing_template['filing']['correction']['correctedFilingId'] = original_filing_id - filing = create_filing(token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity.id) + filing = create_filing( + token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity.id) filing.payment_completion_date = filing.filing_date filing.save() if option in ['COMPLETED']: @@ -505,7 +581,8 @@ def prep_firm_correction_filing(session, identifier, payment_id, legal_type, leg def prep_cp_special_resolution_filing(identifier, payment_id, legal_type, legal_name, submitter_role=None): """Return a new cp special resolution out filing prepped for email notification.""" - legal_entity = create_legal_entity(identifier, legal_type=legal_type, legal_name=legal_name) + legal_entity = create_legal_entity( + identifier, legal_type=legal_type, legal_name=legal_name) filing_template = copy.deepcopy(CP_SPECIAL_RESOLUTION_TEMPLATE) filing_template['filing']['business'] = \ {'identifier': f'{identifier}', 'legalype': legal_type, 'legalName': legal_name} @@ -521,8 +598,10 @@ def prep_cp_special_resolution_filing(identifier, payment_id, legal_type, legal_ 'rulesFileKey': 'cooperative/a8abe1a6-4f45-4105-8a05-822baee3b743.pdf' } if submitter_role: - filing_template['filing']['header']['documentOptionalEmail'] = f'{submitter_role}@email.com' - filing = create_filing(token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity.id) + filing_template['filing']['header'][ + 'documentOptionalEmail'] = f'{submitter_role}@email.com' + filing = create_filing( + token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity.id) user = create_user('cp_test_user') filing.submitter_id = user.id @@ -537,18 +616,55 @@ def prep_cp_special_resolution_correction_filing(session, legal_entity, original """Return a cp special resolution correction filing prepped for email notification.""" filing_template = copy.deepcopy(FILING_HEADER) filing_template['filing']['header']['name'] = 'correction' - filing_template['filing']['correction'] = copy.deepcopy(CORRECTION_CP_SPECIAL_RESOLUTION) - filing_template['filing']['business'] = {'identifier': legal_entity.identifier} + filing_template['filing']['correction'] = copy.deepcopy( + CORRECTION_CP_SPECIAL_RESOLUTION) + filing_template['filing']['business'] = { + 'identifier': legal_entity.identifier} filing_template['filing']['correction']['contactPoint']['email'] = 'cp_sr@test.com' filing_template['filing']['correction']['correctedFilingId'] = original_filing_id filing_template['filing']['correction']['correctedFilingType'] = corrected_filing_type filing_template['filing']['correction']['nameRequest'] = { 'nrNumber': 'NR 8798956', 'legalName': 'HAULER MEDIA INC.', - 'legalType': 'BC' + 'legalType': 'BC', + 'requestType': 'CHG' } - filing = create_filing(token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity.id) + filing = create_filing( + token=payment_id, filing_json=filing_template, legal_entity_id=legal_entity.id) + filing.payment_completion_date = filing.filing_date + # Triggered from the filer. + filing._meta_data = {'correction': { + 'uploadNewRules': True, 'toLegalName': True}} + filing.save() + if option in ['COMPLETED']: + uow = versioning_manager.unit_of_work(session) + transaction = uow.create_transaction(session) + filing.transaction_id = transaction.id + filing.save() + return filing + + +def prep_cp_special_resolution_correction_upload_memorandum_filing(session, business, + original_filing_id, + payment_id, option, + corrected_filing_type): + """Return a cp special resolution correction filing prepped for email notification.""" + filing_template = copy.deepcopy(FILING_HEADER) + filing_template['filing']['header']['name'] = 'correction' + filing_template['filing']['correction'] = copy.deepcopy( + CORRECTION_CP_SPECIAL_RESOLUTION) + filing_template['filing']['business'] = {'identifier': business.identifier} + filing_template['filing']['correction']['contactPoint']['email'] = 'cp_sr@test.com' + filing_template['filing']['correction']['correctedFilingId'] = original_filing_id + filing_template['filing']['correction']['correctedFilingType'] = corrected_filing_type + del filing_template['filing']['correction']['resolution'] + filing_template['filing']['correction']['memorandumFileKey'] = '28f73dc4-8e7c-4c89-bef6-a81dff909ca6.pdf' + filing_template['filing']['correction']['memorandumFileName'] = 'test.pdf' + filing = create_filing( + token=payment_id, filing_json=filing_template, business_id=business.id) filing.payment_completion_date = filing.filing_date + # Triggered from the filer. + filing._meta_data = {'correction': {'uploadNewMemorandum': True}} filing.save() if option in ['COMPLETED']: uow = versioning_manager.unit_of_work(session) @@ -580,6 +696,7 @@ def create_mock_message(message_payload: dict): mock_msg.data.decode = Mock(return_value=json_msg_payload) return mock_msg + @contextmanager def nested_session(session): try: diff --git a/queue_services/entity-emailer/tests/unit/email_processors/test_agm_extension_notification.py b/queue_services/entity-emailer/tests/unit/email_processors/test_agm_extension_notification.py new file mode 100644 index 0000000000..ed10100ab5 --- /dev/null +++ b/queue_services/entity-emailer/tests/unit/email_processors/test_agm_extension_notification.py @@ -0,0 +1,55 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Unit Tests for AGM extension email processor.""" +from unittest.mock import patch + +import pytest +from legal_api.models import LegalEntity + +from entity_emailer.email_processors import agm_extension_notification +from tests.unit import prep_agm_extension_filing + + +@pytest.mark.parametrize("status,legal_name,is_numbered", [ + ("COMPLETED", "test business", False), + ("COMPLETED", "BC1234567", True), +]) +def test_agm_extension_notification(app, session, status, legal_name, is_numbered): + """Assert that the agm extension email processor works as expected.""" + # setup filing + business for email + filing = prep_agm_extension_filing( + "BC1234567", "1", LegalEntity.EntityTypes.COMP.value, legal_name) + token = "token" + # test processor + with patch.object(agm_extension_notification, "_get_pdfs", return_value=[]) as mock_get_pdfs: + with patch.object(agm_extension_notification, "get_recipient_from_auth", + return_value="recipient@email.com"): + email = agm_extension_notification.process( + {"filingId": filing.id, "type": "agmExtension", "option": status}, token) + + if (is_numbered): + assert email["content"]["subject"] == \ + "Numbered Company - AGM Extension Documents from the Business Registry" + else: + assert email["content"]["subject"] == \ + legal_name + " - AGM Extension Documents from the Business Registry" + + assert "recipient@email.com" in email["recipients"] + assert email["content"]["body"] + assert email["content"]["attachments"] == [] + assert mock_get_pdfs.call_args[0][0] == token + assert mock_get_pdfs.call_args[0][1]["identifier"] == "BC1234567" + assert mock_get_pdfs.call_args[0][1]["legalName"] == legal_name + assert mock_get_pdfs.call_args[0][1]["legalType"] == LegalEntity.EntityTypes.COMP.value + assert mock_get_pdfs.call_args[0][2] == filing diff --git a/queue_services/entity-emailer/tests/unit/email_processors/test_agm_location_change_notification.py b/queue_services/entity-emailer/tests/unit/email_processors/test_agm_location_change_notification.py new file mode 100644 index 0000000000..4bf3201f4f --- /dev/null +++ b/queue_services/entity-emailer/tests/unit/email_processors/test_agm_location_change_notification.py @@ -0,0 +1,55 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Unit Tests for AGM location change email processor.""" +from unittest.mock import patch + +import pytest +from legal_api.models import LegalEntity + +from entity_emailer.email_processors import agm_location_change_notification +from tests.unit import prep_agm_location_change_filing + + +@pytest.mark.parametrize("status,legal_name,is_numbered", [ + ("COMPLETED", "test business", False), + ("COMPLETED", "BC1234567", True), +]) +def test_agm_location_change_notification(app, session, status, legal_name, is_numbered): + """Assert that the agm location change email processor works as expected.""" + # setup filing + business for email + filing = prep_agm_location_change_filing( + "BC1234567", "1", LegalEntity.EntityTypes.COMP.value, legal_name) + token = "token" + # test processor + with patch.object(agm_location_change_notification, "_get_pdfs", return_value=[]) as mock_get_pdfs: + with patch.object(agm_location_change_notification, "get_recipient_from_auth", + return_value="recipient@email.com"): + email = agm_location_change_notification.process( + {"filingId": filing.id, "type": "agmLocationChange", "option": status}, token) + + if (is_numbered): + assert email["content"]["subject"] == \ + "Numbered Company - AGM Location Change Documents from the Business Registry" + else: + assert email["content"]["subject"] == \ + legal_name + " - AGM Location Change Documents from the Business Registry" + + assert "recipient@email.com" in email["recipients"] + assert email["content"]["body"] + assert email["content"]["attachments"] == [] + assert mock_get_pdfs.call_args[0][0] == token + assert mock_get_pdfs.call_args[0][1]["identifier"] == "BC1234567" + assert mock_get_pdfs.call_args[0][1]["legalName"] == legal_name + assert mock_get_pdfs.call_args[0][1]["legalType"] == LegalEntity.EntityTypes.COMP.value + assert mock_get_pdfs.call_args[0][2] == filing diff --git a/queue_services/entity-emailer/tests/unit/email_processors/test_ar_reminder_notification.py b/queue_services/entity-emailer/tests/unit/email_processors/test_ar_reminder_notification.py index f8d3ec15b8..3e7681e177 100644 --- a/queue_services/entity-emailer/tests/unit/email_processors/test_ar_reminder_notification.py +++ b/queue_services/entity-emailer/tests/unit/email_processors/test_ar_reminder_notification.py @@ -23,24 +23,25 @@ def test_ar_reminder_notification(app, session): """Assert that the ar reminder notification can be processed.""" # setup filing + business for email - filing = prep_incorp_filing(session, 'BC1234567', '1', 'COMPLETED') + filing = prep_incorp_filing(session, "BC1234567", "1", "COMPLETED") business = LegalEntity.find_by_internal_id(filing.business_id) - business.legal_type = 'BC' - business.legal_name = 'test business' - token = 'token' + business.legal_type = "BC" + business.legal_name = "test business" + token = "token" + flag_on = False # test processor - with patch.object(ar_reminder_notification, 'get_recipient_from_auth', return_value='test@test.com') \ + with patch.object(ar_reminder_notification, "get_recipient_from_auth", return_value="test@test.com") \ as mock_get_recipient_from_auth: email = ar_reminder_notification.process( { - 'businessId': filing.business_id, - 'type': 'annualReport', 'option': 'reminder', - 'arFee': '100', 'arYear': 2021 - }, token) - assert email['content']['subject'] == 'test business 2021 Annual Report Reminder' + "businessId": filing.business_id, + "type": "annualReport", "option": "reminder", + "arFee": "100", "arYear": 2021 + }, token, flag_on) + assert email["content"]["subject"] == "test business 2021 Annual Report Reminder" - assert 'test@test.com' in email['recipients'] - assert email['content']['body'] - assert email['content']['attachments'] == [] - assert mock_get_recipient_from_auth.call_args[0][0] == 'BC1234567' + assert "test@test.com" in email["recipients"] + assert email["content"]["body"] + assert email["content"]["attachments"] == [] + assert mock_get_recipient_from_auth.call_args[0][0] == "BC1234567" assert mock_get_recipient_from_auth.call_args[0][1] == token diff --git a/queue_services/entity-emailer/tests/unit/email_processors/test_correction_notification.py b/queue_services/entity-emailer/tests/unit/email_processors/test_correction_notification.py index 0e5ed7b20c..d53edd4f1e 100644 --- a/queue_services/entity-emailer/tests/unit/email_processors/test_correction_notification.py +++ b/queue_services/entity-emailer/tests/unit/email_processors/test_correction_notification.py @@ -229,7 +229,7 @@ def test_paid_special_resolution_correction_attachments(session, config): assert 'content' in output assert 'attachments' in output['content'] assert len(output['content']['attachments']) == 2 - assert output['content']['attachments'][0]['fileName'] == 'Special Resolution Correction Application.pdf' + assert output['content']['attachments'][0]['fileName'] == 'Register Correction Application.pdf' assert base64.b64decode(output['content']['attachments'][0]['fileBytes']).decode('utf-8') == 'pdf_content_1' assert output['content']['attachments'][1]['fileName'] == 'Receipt.pdf' assert base64.b64decode(output['content']['attachments'][1]['fileBytes']).decode('utf-8') == 'pdf_content_2' diff --git a/queue_services/entity-emailer/tests/unit/email_processors/test_nr_notification.py b/queue_services/entity-emailer/tests/unit/email_processors/test_nr_notification.py index d8b17fddf9..917c559948 100644 --- a/queue_services/entity-emailer/tests/unit/email_processors/test_nr_notification.py +++ b/queue_services/entity-emailer/tests/unit/email_processors/test_nr_notification.py @@ -45,6 +45,7 @@ def test_nr_notification(app, session, option, nr_number, subject, expiration_da nr_json = { 'expirationDate': expiration_date, 'names': names, + 'legalType': 'BC', 'applicants': { 'emailAddress': 'test@test.com' } diff --git a/queue_services/entity-emailer/tests/unit/test_worker.py b/queue_services/entity-emailer/tests/unit/test_worker.py index 71cb50f552..78beb7bfc9 100644 --- a/queue_services/entity-emailer/tests/unit/test_worker.py +++ b/queue_services/entity-emailer/tests/unit/test_worker.py @@ -63,6 +63,7 @@ ) from tests.unit import nested_session + def test_no_message(client): """Return a 4xx when an no JSON present.""" @@ -117,12 +118,13 @@ def test_process_incorp_email(app, session, client, option): # Setup filing = prep_incorp_filing(session, 'BC1234567', '1', option, 'BC') token = '1' - email_msg = {'email': {'filingId': filing.id, 'type': 'incorporationApplication', 'option': option}} + email_msg = {'email': {'filingId': filing.id, + 'type': 'incorporationApplication', 'option': option}} message = helper_create_cloud_event_envelope(data=email_msg) with patch.object(AccountService, 'get_bearer_token', return_value=token): with patch.object(filing_notification, '_get_pdfs', return_value=[]) as mock_get_pdfs: - with patch.object(worker, 'send_email', return_value='success') as mock_send_email: + with patch.object(worker, 'send_email', return_value='success') as mock_send_email: with patch.object(queue, "publish", return_value={}): # TEST rv = client.post("/", json=message) @@ -133,7 +135,8 @@ def test_process_incorp_email(app, session, client, option): assert mock_get_pdfs.call_args[0][0] == option assert mock_get_pdfs.call_args[0][1] == token if option == 'PAID': - assert mock_get_pdfs.call_args[0][2]['identifier'].startswith('T') + assert mock_get_pdfs.call_args[0][2]['identifier'].startswith( + 'T') else: assert mock_get_pdfs.call_args[0][2]['identifier'] == 'BC1234567' @@ -149,7 +152,8 @@ def test_process_incorp_email(app, session, client, option): 'Incorporation Documents from the Business Registry' assert 'test@test.com' in mock_send_email.call_args[0][0]['recipients'] assert mock_send_email.call_args[0][0]['content']['body'] - assert mock_send_email.call_args[0][0]['content']['attachments'] == [] + assert mock_send_email.call_args[0][0]['content']['attachments'] == [ + ] assert mock_send_email.call_args[0][1] == token @@ -163,9 +167,11 @@ def test_process_incorp_email(app, session, client, option): def test_maintenance_notification(app, session, client, status, filing_type): """Assert that the legal name is changed.""" # Setup - filing = prep_maintenance_filing(session, 'BC1234567', '1', status, filing_type) + filing = prep_maintenance_filing( + session, 'BC1234567', '1', status, filing_type) token = 'token' - email_msg = {'email': {'filingId': filing.id, 'type': f'{filing_type}', 'option': status}} + email_msg = {'email': {'filingId': filing.id, + 'type': f'{filing_type}', 'option': status}} message = helper_create_cloud_event_envelope(data=email_msg) with patch.object(AccountService, 'get_bearer_token', return_value=token): @@ -176,10 +182,10 @@ def test_maintenance_notification(app, session, client, status, filing_type): with patch.object(queue, "publish", return_value={}): # TEST rv = client.post("/", json=message) - + # Check assert rv.status_code == HTTPStatus.OK - + assert mock_get_pdfs.call_args[0][0] == status assert mock_get_pdfs.call_args[0][1] == token @@ -195,7 +201,8 @@ def test_maintenance_notification(app, session, client, status, filing_type): assert mock_send_email.call_args[0][0]['content']['subject'] assert 'test@test.com' in mock_send_email.call_args[0][0]['recipients'] assert mock_send_email.call_args[0][0]['content']['body'] - assert mock_send_email.call_args[0][0]['content']['attachments'] == [] + assert mock_send_email.call_args[0][0]['content']['attachments'] == [ + ] assert mock_send_email.call_args[0][1] == token @@ -209,9 +216,11 @@ def test_maintenance_notification(app, session, client, status, filing_type): def test_skips_notification(app, session, client, status, filing_type, identifier): """Assert that the legal name is changed.""" # Setup - filing = prep_maintenance_filing(session, identifier, '1', status, filing_type) + filing = prep_maintenance_filing( + session, identifier, '1', status, filing_type) token = 'token' - email_msg = {'email': {'filingId': filing.id, 'type': f'{filing_type}', 'option': status}} + email_msg = {'email': {'filingId': filing.id, + 'type': f'{filing_type}', 'option': status}} message = helper_create_cloud_event_envelope(data=email_msg) with patch.object(AccountService, 'get_bearer_token', return_value=token): @@ -231,7 +240,8 @@ def test_process_mras_email(app, session, client): # Setup filing = prep_incorp_filing(session, 'BC1234567', '1', 'mras') token = '1' - email_msg = {'email': {'filingId': filing.id, 'type': 'incorporationApplication', 'option': 'mras'}} + email_msg = {'email': {'filingId': filing.id, + 'type': 'incorporationApplication', 'option': 'mras'}} message = helper_create_cloud_event_envelope(data=email_msg) with patch.object(AccountService, 'get_bearer_token', return_value=token): @@ -246,7 +256,8 @@ def test_process_mras_email(app, session, client): assert mock_send_email.call_args[0][0]['content']['subject'] == 'BC Business Registry Partner Information' assert mock_send_email.call_args[0][0]['recipients'] == 'test@test.com' assert mock_send_email.call_args[0][0]['content']['body'] - assert mock_send_email.call_args[0][0]['content']['attachments'] == [] + assert mock_send_email.call_args[0][0]['content']['attachments'] == [ + ] assert mock_send_email.call_args[0][1] == token @@ -257,10 +268,12 @@ def test_process_mras_email(app, session, client): def test_process_special_resolution_email(app, session, client, option, submitter_role): """Assert that an special resolution email msg is processed correctly.""" # Setup - filing = prep_cp_special_resolution_filing('CP1234567', '1', 'CP', 'TEST', submitter_role=submitter_role) + filing = prep_cp_special_resolution_filing( + 'CP1234567', '1', 'CP', 'TEST', submitter_role=submitter_role) token = '1' get_pdf_function = 'get_paid_pdfs' if option == 'PAID' else 'get_completed_pdfs' - email_msg = {'email': {'filingId': filing.id, 'type': 'specialResolution', 'option': option}} + email_msg = {'email': {'filingId': filing.id, + 'type': 'specialResolution', 'option': option}} message = helper_create_cloud_event_envelope(data=email_msg) with patch.object(AccountService, 'get_bearer_token', return_value=token): @@ -293,7 +306,8 @@ def test_process_special_resolution_email(app, session, client, option, submitte else: assert 'user@email.com' in mock_send_email.call_args[0][0]['recipients'] assert mock_send_email.call_args[0][0]['content']['body'] - assert mock_send_email.call_args[0][0]['content']['attachments'] == [] + assert mock_send_email.call_args[0][0]['content']['attachments'] == [ + ] assert mock_send_email.call_args[0][1] == token @@ -305,12 +319,14 @@ def test_process_correction_cp_sr_email(app, session, client, option): """Assert that a correction email msg is processed correctly.""" # Setup identifier = 'CP1234567' - original_filing = prep_cp_special_resolution_filing(identifier, '1', 'CP', 'TEST', submitter_role=None) + original_filing = prep_cp_special_resolution_filing( + identifier, '1', 'CP', 'TEST', submitter_role=None) token = '1' business = LegalEntity.find_by_identifier(identifier) filing = prep_cp_special_resolution_correction_filing(session, business, original_filing.id, '1', option, 'specialResolution') - email_msg = {'email': {'filingId': filing.id, 'type': 'correction', 'option': option}} + email_msg = {'email': {'filingId': filing.id, + 'type': 'correction', 'option': option}} message = helper_create_cloud_event_envelope(data=email_msg) with patch.object(AccountService, 'get_bearer_token', return_value=token): @@ -331,7 +347,8 @@ def test_process_correction_cp_sr_email(app, session, client, option): 'TEST - Correction Documents from the Business Registry' assert 'cp_sr@test.com' in mock_send_email.call_args[0][0]['recipients'] assert mock_send_email.call_args[0][0]['content']['body'] - assert mock_send_email.call_args[0][0]['content']['attachments'] == [] + assert mock_send_email.call_args[0][0]['content']['attachments'] == [ + ] assert mock_send_email.call_args[0][1] == token @@ -344,10 +361,10 @@ def test_process_ar_reminder_email(app, session, client): business.legal_name = 'test business' token = 'token' email_msg = {'email': { - 'businessId': filing.business_id, - 'type': 'annualReport', 'option': 'reminder', - 'arFee': '100', 'arYear': '2021' - }} + 'businessId': filing.business_id, + 'type': 'annualReport', 'option': 'reminder', + 'arFee': '100', 'arYear': '2021' + }} message = helper_create_cloud_event_envelope(data=email_msg) with patch.object(AccountService, 'get_bearer_token', return_value=token): @@ -364,6 +381,7 @@ def test_process_ar_reminder_email(app, session, client): assert call_args[0][0]['content']['subject'] == 'test business 2021 Annual Report Reminder' assert call_args[0][0]['recipients'] == 'test@test.com' assert call_args[0][0]['content']['body'] + assert 'Dye & Durham' not in call_args[0][0]['content']['body'] assert call_args[0][0]['content']['attachments'] == [] assert call_args[0][1] == token @@ -374,7 +392,8 @@ def test_process_bn_email(app, session, client): identifier = 'BC1234567' filing = prep_incorp_filing(session, identifier, '1', 'bn') business = LegalEntity.find_by_identifier(identifier) - email_msg = {'email': {'filingId': None, 'type': 'businessNumber', 'option': 'bn', 'identifier': 'BC1234567'}} + email_msg = {'email': {'filingId': None, 'type': 'businessNumber', + 'option': 'bn', 'identifier': 'BC1234567'}} message = helper_create_cloud_event_envelope(data=email_msg) # Sanity check @@ -396,7 +415,8 @@ def test_process_bn_email(app, session, client): assert mock_send_email.call_args[0][0]['content']['subject'] == \ f'{business.legal_name} - Business Number Information' assert mock_send_email.call_args[0][0]['content']['body'] - assert mock_send_email.call_args[0][0]['content']['attachments'] == [] + assert mock_send_email.call_args[0][0]['content']['attachments'] == [ + ] default_legal_name = 'TEST COMP' @@ -411,9 +431,12 @@ def test_process_bn_email(app, session, client): [{'name': 'TEST3 Company Name', 'state': 'CONDITION'}, {'name': 'TEST4 Company Name', 'state': 'NE'}]), ('expired', 'NR 1234567', 'Expired', None, None, 'TEST4 Company Name', [{'name': 'TEST5 Company Name', 'state': 'NE'}, {'name': 'TEST4 Company Name', 'state': 'APPROVED'}]), - ('renewal', 'NR 1234567', 'Confirmation of Renewal', '2021-07-20T00:00:00+00:00', None, None, default_names_array), - ('upgrade', 'NR 1234567', 'Confirmation of Upgrade', None, None, None, default_names_array), - ('refund', 'NR 1234567', 'Refund request confirmation', None, '123.45', None, default_names_array) + ('renewal', 'NR 1234567', 'Confirmation of Renewal', + '2021-07-20T00:00:00+00:00', None, None, default_names_array), + ('upgrade', 'NR 1234567', 'Confirmation of Upgrade', + None, None, None, default_names_array), + ('refund', 'NR 1234567', 'Refund request confirmation', + None, '123.45', None, default_names_array) ]) def test_nr_notification(app, session, client, option, nr_number, subject, expiration_date, refund_value, expected_legal_name, names): @@ -422,6 +445,7 @@ def test_nr_notification(app, session, client, option, nr_number, subject, expir nr_json = { 'expirationDate': expiration_date, 'names': names, + 'legalType': 'BC', 'applicants': { 'emailAddress': 'test@test.com' } @@ -468,8 +492,10 @@ def test_nr_notification(app, session, client, option, nr_number, subject, expir assert nr_number in call_args[0][0]['content']['body'] assert expected_legal_name in call_args[0][0]['content']['body'] exp_date = datetime.fromisoformat(expiration_date) - exp_date_tz = LegislationDatetime.as_legislation_timezone(exp_date) - assert_expiration_date = LegislationDatetime.format_as_report_string(exp_date_tz) + exp_date_tz = LegislationDatetime.as_legislation_timezone( + exp_date) + assert_expiration_date = LegislationDatetime.format_as_report_string( + exp_date_tz) assert assert_expiration_date in call_args[0][0]['content']['body'] if option == nr_notification.Option.EXPIRED.value: @@ -525,7 +551,8 @@ def test_nr_receipt_notification(app, session, client): assert mock_pdf.call_args[0][1] == payment_token assert mock_query_nr_number.call_args[0][0] == nr_number call_args = mock_send_email.call_args - assert call_args[0][0]['content']['subject'] == f'{nr_number} - Receipt from Corporate Registry' + assert call_args[0][0]['content'][ + 'subject'] == f'{nr_number} - Receipt from Corporate Registry' assert call_args[0][0]['recipients'] == email_address assert call_args[0][0]['content']['body'] assert call_args[0][0]['content']['attachments'] == pdfs @@ -599,9 +626,9 @@ def helper_create_cloud_event_envelope( ): if not data: data = { - "email": { - "type": "bn", - } + "email": { + "type": "bn", + } } if not ce: ce = SimpleCloudEvent( From c552d3ddf96110a6067a653da14e68719e651cb3 Mon Sep 17 00:00:00 2001 From: "vysakh.menon" Date: Fri, 19 Jan 2024 16:21:40 -0800 Subject: [PATCH 2/3] entity filer --- .../filing_processors/agm_extension.py | 64 ++++++ .../filing_processors/agm_location_change.py | 27 +++ .../amalgamation_application.py | 199 ++++++++++++++++++ .../filing_processors/correction.py | 7 +- .../filing_processors/dissolution.py | 5 +- .../filing_components/correction.py | 22 ++ .../filing_components/rules_and_memorandum.py | 11 +- .../filing_processors/incorporation_filing.py | 9 +- .../src/entity_filer/resources/worker.py | 26 ++- .../src/entity_filer/utils/utils.py | 37 ++-- queue_services/entity-filer/tests/conftest.py | 6 + .../test_agm_location_change.py | 54 +++++ .../unit/filing_processors/test_alteration.py | 2 +- .../test_incorporation_filing.py | 4 +- .../tests/unit/worker/test_agm_extension.py | 91 ++++++++ .../worker/test_amalgamation_application.py | 100 +++++++++ .../test_correction_special_resolution.py | 41 ++-- 17 files changed, 653 insertions(+), 52 deletions(-) create mode 100644 queue_services/entity-filer/src/entity_filer/filing_processors/agm_extension.py create mode 100644 queue_services/entity-filer/src/entity_filer/filing_processors/agm_location_change.py create mode 100644 queue_services/entity-filer/src/entity_filer/filing_processors/amalgamation_application.py create mode 100644 queue_services/entity-filer/tests/unit/filing_processors/test_agm_location_change.py create mode 100644 queue_services/entity-filer/tests/unit/worker/test_agm_extension.py create mode 100644 queue_services/entity-filer/tests/unit/worker/test_amalgamation_application.py diff --git a/queue_services/entity-filer/src/entity_filer/filing_processors/agm_extension.py b/queue_services/entity-filer/src/entity_filer/filing_processors/agm_extension.py new file mode 100644 index 0000000000..a49c70dd6c --- /dev/null +++ b/queue_services/entity-filer/src/entity_filer/filing_processors/agm_extension.py @@ -0,0 +1,64 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""File processing rules and actions for the agm extension filing.""" + +from typing import Dict + +import dpath + +from entity_filer.filing_meta import FilingMeta + + +def process(filing: Dict, filing_meta: FilingMeta): + """Render the agm extension filing onto the business model objects.""" + filing_meta.agm_extension = { + "year": filing["agmExtension"]["year"], + "isFirstAgm": filing["agmExtension"]["isFirstAgm"], + "extReqForAgmYear": filing["agmExtension"]["extReqForAgmYear"], + "totalApprovedExt": filing["agmExtension"]["totalApprovedExt"], + "extensionDuration": filing["agmExtension"]["extensionDuration"], + "isFinalExtension": _check_final_extension(filing) + } + + if prev_agm_ref_date := dpath.util.get(filing, "/agmExtension/prevAgmRefDate", default=None): + filing_meta.agm_extension = { + **filing_meta.agm_extension, + "prevAgmRefDate": prev_agm_ref_date + } + + if curr_ext_expiry_date := dpath.util.get(filing, "/agmExtension/expireDateCurrExt", default=None): + filing_meta.agm_extension = { + **filing_meta.agm_extension, + "expireDateCurrExt": curr_ext_expiry_date + } + + if intended_agm_date := dpath.util.get(filing, "/agmExtension/intendedAgmDate", default=None): + filing_meta.agm_extension = { + **filing_meta.agm_extension, + "intendedAgmDate": intended_agm_date + } + + if expiry_date_approved_ext := dpath.util.get(filing, "/agmExtension/expireDateApprovedExt", default=None): + filing_meta.agm_extension = { + **filing_meta.agm_extension, + "expireDateApprovedExt": expiry_date_approved_ext + } + + +def _check_final_extension(filing: Dict) -> bool: + """Mark final extension for current agm year.""" + total_approved_ext = filing["agmExtension"]["totalApprovedExt"] + if total_approved_ext >= 12: + return True + return False diff --git a/queue_services/entity-filer/src/entity_filer/filing_processors/agm_location_change.py b/queue_services/entity-filer/src/entity_filer/filing_processors/agm_location_change.py new file mode 100644 index 0000000000..455e0c3904 --- /dev/null +++ b/queue_services/entity-filer/src/entity_filer/filing_processors/agm_location_change.py @@ -0,0 +1,27 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""File processing rules and actions for the agm location change filing.""" + +from typing import Dict + +from entity_filer.filing_meta import FilingMeta + + +def process(filing: Dict, filing_meta: FilingMeta): + """Render the agm location change filing into the model objects.""" + filing_meta.agm_location_change = { + "year": filing["agmLocationChange"]["year"], + "agmLocation": filing["agmLocationChange"]["agmLocation"], + "reason": filing["agmLocationChange"]["reason"] + } diff --git a/queue_services/entity-filer/src/entity_filer/filing_processors/amalgamation_application.py b/queue_services/entity-filer/src/entity_filer/filing_processors/amalgamation_application.py new file mode 100644 index 0000000000..1a5e3dbce9 --- /dev/null +++ b/queue_services/entity-filer/src/entity_filer/filing_processors/amalgamation_application.py @@ -0,0 +1,199 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""File processing rules and actions for the amalgamation application of a business.""" +import copy +from contextlib import suppress +from http import HTTPStatus +from typing import Dict + +import sentry_sdk + +# from entity_filer.exceptions import DefaultException +from business_model import db, AmalgamatingBusiness, Amalgamation, LegalEntity, Document, Filing, RegistrationBootstrap + +# from legal_api.services.bootstrap import AccountService + +from entity_filer.filing_meta import FilingMeta +from entity_filer.filing_processors.filing_components import aliases, filings, legal_entity_info, shares +from entity_filer.filing_processors.filing_components.offices import update_offices +from entity_filer.filing_processors.filing_components.parties import merge_all_parties + + +def update_affiliation(business: LegalEntity, filing: Filing): + """Create an affiliation for the business and remove the bootstrap.""" + # TODO remove all of this + pass + # try: + # bootstrap = RegistrationBootstrap.find_by_identifier(filing.temp_reg) + + # nr_number = (filing.filing_json + # .get("filing") + # .get("amalgamationApplication", {}) + # .get("nameRequest", {}) + # .get("nrNumber")) + # details = { + # "bootstrapIdentifier": bootstrap.identifier, + # "identifier": business.identifier, + # "nrNumber": nr_number + # } + + # rv = AccountService.create_affiliation( + # account=bootstrap.account, + # business_registration=business.identifier, + # business_name=business.legal_name, + # corp_type_code=business.legal_type, + # details=details + # ) + + # if rv not in (HTTPStatus.OK, HTTPStatus.CREATED): + # deaffiliation = AccountService.delete_affiliation(bootstrap.account, business.identifier) + # sentry_sdk.capture_message( + # f"Queue Error: Unable to affiliate business:{business.identifier} for filing:{filing.id}", + # level="error" + # ) + # else: + # update the bootstrap to use the new business identifier for the name + # bootstrap_update = AccountService.update_entity( + # business_registration=bootstrap.identifier, + # business_name=business.identifier, + # corp_type_code="ATMP" + # ) + + # if rv not in (HTTPStatus.OK, HTTPStatus.CREATED) \ + # or ("deaffiliation" in locals() and deaffiliation != HTTPStatus.OK)\ + # or ("bootstrap_update" in locals() and bootstrap_update != HTTPStatus.OK): + # raise DefaultException + # except Exception as err: # pylint: disable=broad-except; note out any exception, but don"t fail the call + # sentry_sdk.capture_message( + # f"Queue Error: Affiliation error for filing:{filing.id}, with err:{err}", + # level="error" + # ) + + +def create_amalgamating_businesses(amalgamation_filing: Dict, amalgamation: Amalgamation, filing_rec: Filing): + """Create amalgamating businesses.""" + amalgamating_businesses_json = amalgamation_filing.get("amalgamatingBusinesses", []) + for amalgamating_business_json in amalgamating_businesses_json: + amalgamating_business = AmalgamatingBusiness() + amalgamating_business.role = amalgamating_business_json.get("role") + if ((identifier := amalgamating_business_json.get("identifier")) and + (business := LegalEntity.find_by_identifier(identifier))): + amalgamating_business.legal_entity_id = business.id + dissolve_amalgamating_business(business, filing_rec) + else: + amalgamating_business.foreign_corp_num = amalgamating_business_json.get("corpNumber") + amalgamating_business.foreign_name = amalgamating_business_json.get("legalName") + + foreign_jurisdiction = amalgamating_business_json.get("foreignJurisdiction") + amalgamating_business.foreign_jurisdiction = foreign_jurisdiction.get("country").upper() + if region := foreign_jurisdiction.get("region"): + amalgamating_business.foreign_jurisdiction_region = region.upper() + + amalgamation.amalgamating_businesses.append(amalgamating_business) + + +def dissolve_amalgamating_business(business: LegalEntity, filing_rec: Filing): + """Dissolve amalgamating business.""" + business.dissolution_date = filing_rec.effective_date + business.state = LegalEntity.State.HISTORICAL + business.state_filing_id = filing_rec.id + db.session.add(business) + + +def process(business: LegalEntity, # pylint: disable=too-many-branches + filing: Dict, + filing_rec: Filing, + filing_meta: FilingMeta): # pylint: disable=too-many-branches + """Process the incoming amalgamation application filing.""" + # Extract the filing information for amalgamation + amalgamation_filing = filing.get("filing", {}).get("amalgamationApplication") + filing_meta.amalgamation_application = {} + amalgamation = Amalgamation() + + if not amalgamation_filing: + raise DefaultException( + f"AmalgamationApplication legal_filing:amalgamationApplication missing from {filing_rec.id}") + if business: + raise DefaultException( + f"LegalEntity Already Exist: AmalgamationApplication legal_filing:amalgamationApplication {filing_rec.id}") + + business_info_obj = amalgamation_filing.get("nameRequest") + + # Reserve the Corp Number for this entity + corp_num = legal_entity_info.get_next_corp_num(business_info_obj["legalType"]) + if not corp_num: + raise DefaultException( + f"amalgamationApplication {filing_rec.id} unable to get a business amalgamationApplication number.") + + # Initial insert of the business record + business = LegalEntity() + business = legal_entity_info.update_legal_entity_info( + corp_num, business, business_info_obj, filing_rec + ) + business.state = LegalEntity.State.ACTIVE + + amalgamation.filing_id = filing_rec.id + amalgamation.amalgamation_type = amalgamation_filing.get("type") + amalgamation.amalgamation_date = filing_rec.effective_date + amalgamation.court_approval = bool(amalgamation_filing.get("courtApproval")) + create_amalgamating_businesses(amalgamation_filing, amalgamation, filing_rec) + business.amalgamation.append(amalgamation) + + if nr_number := business_info_obj.get("nrNumber", None): + filing_meta.amalgamation_application = {**filing_meta.amalgamation_application, + **{"nrNumber": nr_number, + "legalName": business_info_obj.get("legalName", None)}} + + if not business: + raise DefaultException(f"amalgamationApplication {filing_rec.id}, Unable to create business.") + + if offices := amalgamation_filing.get("offices"): + update_offices(business, offices) + + if parties := amalgamation_filing.get("parties"): + merge_all_parties(business, filing_rec, {"parties": parties}) + + if share_structure := amalgamation_filing.get("shareStructure"): + shares.update_share_structure(business, share_structure) + + if name_translations := amalgamation_filing.get("nameTranslations"): + aliases.update_aliases(business, name_translations) + + if court_order := amalgamation_filing.get("courtOrder"): + filings.update_filing_court_order(filing_rec, court_order) + + # Update the filing json with identifier and founding date. + amalgamation_json = copy.deepcopy(filing_rec.filing_json) + amalgamation_json["filing"]["business"] = {} + amalgamation_json["filing"]["business"]["identifier"] = business.identifier + amalgamation_json["filing"]["business"]["legalType"] = business.entity_type + amalgamation_json["filing"]["business"]["foundingDate"] = business.founding_date.isoformat() + filing_rec._filing_json = amalgamation_json # pylint: disable=protected-access; bypass to update filing data + + return business, filing_rec, filing_meta + + +def post_process(business: LegalEntity, filing: Filing): + """Post processing activities for amalgamation application. + + THIS SHOULD NOT ALTER THE MODEL + """ + # with suppress(IndexError, KeyError, TypeError): + # if err := business_profile.update_business_profile( + # business, + # filing.json["filing"]["amalgamationApplication"]["contactPoint"] + # ): + # sentry_sdk.capture_message( + # f"Queue Error: Update LegalEntity for filing:{filing.id}, error:{err}", + # level="error") diff --git a/queue_services/entity-filer/src/entity_filer/filing_processors/correction.py b/queue_services/entity-filer/src/entity_filer/filing_processors/correction.py index f181a11f2b..36aff4b03f 100644 --- a/queue_services/entity-filer/src/entity_filer/filing_processors/correction.py +++ b/queue_services/entity-filer/src/entity_filer/filing_processors/correction.py @@ -18,7 +18,6 @@ import pytz import sentry_sdk -# from legal_api.core.filing_helper import is_special_resolution_correction from business_model import LegalEntity, Comment, Filing from entity_filer.filing_meta import FilingMeta @@ -60,12 +59,8 @@ def process( ) corrected_filing_type = filing["correction"]["correctedFilingType"] - # TODO i think we can remove this -> is_special_resolution_correction - # is_sr_correction = is_special_resolution_correction(filing, business, original_filing) - # if (business.legal_type in ['SP', 'GP', 'BC', 'BEN', 'CC', 'ULC'] or - # is_sr_correction) and \ if ( - business.entity_type in ["SP", "GP", "BC", "BEN", "CC", "ULC"] + business.entity_type in ["SP", "GP", "BC", "BEN", "CC", "ULC", "CP"] and corrected_filing_type != "conversion" ): correct_business_data(business, correction_filing, filing, filing_meta) diff --git a/queue_services/entity-filer/src/entity_filer/filing_processors/dissolution.py b/queue_services/entity-filer/src/entity_filer/filing_processors/dissolution.py index 8a3dfab55a..0090687dd5 100644 --- a/queue_services/entity-filer/src/entity_filer/filing_processors/dissolution.py +++ b/queue_services/entity-filer/src/entity_filer/filing_processors/dissolution.py @@ -25,6 +25,7 @@ from business_model.models.filing import DissolutionTypes # from legal_api.services.minio import MinioService +# from legal_api.services.pdf_service import RegistrarStampData from entity_filer.utils.legislation_datetime import LegislationDatetime from entity_filer.exceptions import BusinessException, get_error_message, ErrorCode @@ -125,7 +126,9 @@ def _update_cooperative( # # create certified copy for affidavit document # affidavit_file_key = dissolution_filing.get('affidavitFileKey') # affidavit_file = MinioService.get_file(affidavit_file_key) - # replace_file_with_certified_copy(affidavit_file.data, business, affidavit_file_key, filing.effective_date) + # registrar_stamp_data = RegistrarStampData(filing.effective_date, business.identifier) + + # replace_file_with_certified_copy(affidavit_file.data, business, affidavit_file_key, registrar_stamp_data) # document = Document() # document.type = DocumentType.AFFIDAVIT.value diff --git a/queue_services/entity-filer/src/entity_filer/filing_processors/filing_components/correction.py b/queue_services/entity-filer/src/entity_filer/filing_processors/filing_components/correction.py index 69f85c9f27..0ff87bdd6e 100644 --- a/queue_services/entity-filer/src/entity_filer/filing_processors/filing_components/correction.py +++ b/queue_services/entity-filer/src/entity_filer/filing_processors/filing_components/correction.py @@ -136,6 +136,8 @@ def correct_business_data( resolution = dpath.util.get(correction_filing, "/correction/resolution") filings.update_filing_json(correction_filing_rec, resolution) resolutions.update_resolution(business, resolution) + if resolution: + filing_meta.correction = {**filing_meta.correction, **{"hasResolution": True}} # update signatory, if any with suppress(IndexError, KeyError, TypeError): @@ -167,6 +169,26 @@ def correct_business_data( **{"uploadNewRules": True}, } + # update memorandum, if any + with suppress(IndexError, KeyError, TypeError): + memorandum_file_key = dpath.util.get(correction_filing, "/correction/memorandumFileKey") + memorandum_file_name = dpath.util.get(correction_filing, "/correction/memorandumFileName") + if memorandum_file_key: + rules_and_memorandum.update_memorandum(business, correction_filing_rec, + memorandum_file_key, memorandum_file_name) + filing_meta.correction = {**filing_meta.correction, + **{"uploadNewMemorandum": True}} + + with suppress(IndexError, KeyError, TypeError): + if dpath.util.get(correction_filing, "/correction/memorandumInResolution"): + filing_meta.correction = {**filing_meta.correction, + **{"memorandumInResolution": True}} + + with suppress(IndexError, KeyError, TypeError): + if dpath.util.get(correction_filing, "/correction/rulesInResolution"): + filing_meta.correction = {**filing_meta.correction, + **{"rulesInResolution": True}} + def update_parties(business: LegalEntity, parties: list, correction_filing_rec: Filing): """Create a new party or get them if they already exist.""" diff --git a/queue_services/entity-filer/src/entity_filer/filing_processors/filing_components/rules_and_memorandum.py b/queue_services/entity-filer/src/entity_filer/filing_processors/filing_components/rules_and_memorandum.py index 4c22653e73..b188c62c58 100644 --- a/queue_services/entity-filer/src/entity_filer/filing_processors/filing_components/rules_and_memorandum.py +++ b/queue_services/entity-filer/src/entity_filer/filing_processors/filing_components/rules_and_memorandum.py @@ -18,6 +18,7 @@ from typing import List, Optional from business_model import LegalEntity, Document, Filing +# from legal_api.services.pdf_service import RegistrarStampData # from business_model.document import DocumentType # from legal_api.services.minio import MinioService @@ -36,6 +37,10 @@ def update_rules( # TODO Document stamping? raise Exception + is_correction = filing.filing_type == "correction" + rules_file = MinioService.get_file(rules_file_key) + registrar_stamp_data = RegistrarStampData(filing.effective_date, business.identifier, file_name, is_correction) + replace_file_with_certified_copy(rules_file.data, rules_file_key, registrar_stamp_data) # Assumption: rules file key and name have already been validated #""" @@ -57,7 +62,7 @@ def update_rules( def update_memorandum( - business: LegalEntity, filing: Filing, memorandum_file_key: String + business: LegalEntity, filing: Filing, memorandum_file_key: String, file_name: String = None ) -> Optional[List]: """Updtes memorandum if any. @@ -67,9 +72,11 @@ def update_memorandum( # if nothing is passed in, we don't care and it's not an error return None + is_correction = filing.filing_type == "correction" # create certified copy for memorandum document # memorandum_file = MinioService.get_file(memorandum_file_key) - # replace_file_with_certified_copy(memorandum_file.data, business, memorandum_file_key, business.founding_date) + # registrar_stamp_data = RegistrarStampData(filing.effective_date, business.identifier, file_name, is_correction) + # replace_file_with_certified_copy(memorandum_file.data, memorandum_file_key, registrar_stamp_data) # document = Document() # document.type = DocumentType.COOP_MEMORANDUM.value diff --git a/queue_services/entity-filer/src/entity_filer/filing_processors/incorporation_filing.py b/queue_services/entity-filer/src/entity_filer/filing_processors/incorporation_filing.py index 26583d0889..f88d626ae7 100644 --- a/queue_services/entity-filer/src/entity_filer/filing_processors/incorporation_filing.py +++ b/queue_services/entity-filer/src/entity_filer/filing_processors/incorporation_filing.py @@ -25,6 +25,7 @@ # from legal_api.services.bootstrap import AccountService # from legal_api.services.minio import MinioService +# from legal_api.services.pdf_service import RegistrarStampData from entity_filer.filing_meta import FilingMeta from entity_filer.filing_processors.filing_components import ( @@ -85,7 +86,9 @@ def _update_cooperative(incorp_filing: Dict, business: LegalEntity, filing: Fili # # create certified copy for rules document # rules_file_key = cooperative_obj.get('rulesFileKey') # rules_file = MinioService.get_file(rules_file_key) - # replace_file_with_certified_copy(rules_file.data, business, rules_file_key, business.founding_date) + # registrar_stamp_data = RegistrarStampData(business.founding_date, business.identifier) + + # replace_file_with_certified_copy(rules_file.data, rules_file_key, registrar_stamp_data) # business.association_type = cooperative_obj.get('cooperativeAssociationType') # document = Document() @@ -98,7 +101,9 @@ def _update_cooperative(incorp_filing: Dict, business: LegalEntity, filing: Fili # # create certified copy for memorandum document # memorandum_file_key = cooperative_obj.get('memorandumFileKey') # memorandum_file = MinioService.get_file(memorandum_file_key) - # replace_file_with_certified_copy(memorandum_file.data, business, memorandum_file_key, business.founding_date) + # registrar_stamp_data = RegistrarStampData(business.founding_date, business.identifier) + # replace_file_with_certified_copy(memorandum_file.data, memorandum_file_key, registrar_stamp_data) + # document = Document() # document.type = DocumentType.COOP_MEMORANDUM.value diff --git a/queue_services/entity-filer/src/entity_filer/resources/worker.py b/queue_services/entity-filer/src/entity_filer/resources/worker.py index e44fac57f3..851d0a10a8 100644 --- a/queue_services/entity-filer/src/entity_filer/resources/worker.py +++ b/queue_services/entity-filer/src/entity_filer/resources/worker.py @@ -66,7 +66,10 @@ from entity_filer.filing_meta import FilingMeta, json_serial from entity_filer.filing_processors import ( admin_freeze, + agm_extension, + agm_location_change, alteration, + amalgamation_application, annual_report, change_of_address, change_of_directors, @@ -273,6 +276,17 @@ def process_filing( business, {filing_type: filing}, filing_submission, filing_meta ) + case "agmExtension": + agm_extension.process(filing, filing_meta) + + case "agmLocationChange": + agm_location_change.process(filing, filing_meta) + + case "amalgamationApplication": + business, filing_submission, filing_meta = amalgamation_application.process( + business, filing_submission.json, filing_submission, filing_meta + ) + case "alteration": alteration.process( business, filing_submission, {filing_type: filing}, filing_meta @@ -471,7 +485,7 @@ def process_filing( # ) if any("registration" in x for x in legal_filings): - filing_submission.business_id = business.id + filing_submission.legal_entity_id = business.id db.session.add(filing_submission) db.session.commit() # TODO @@ -480,6 +494,16 @@ def process_filing( # name_request.consume_nr(business, filing_submission, 'registration') # registration.post_process(business, filing_submission) + if any('amalgamationApplication' in x for x in legal_filings): + filing_submission.legal_entity_id = business.id + db.session.add(filing_submission) + db.session.commit() + # TODO + pass + # amalgamation_application.update_affiliation(business, filing_submission) + # name_request.consume_nr(business, filing_submission, 'amalgamationApplication') + # amalgamation_application.post_process(business, filing_submission) + if any("changeOfName" in x for x in legal_filings): change_of_name.post_process(business, filing_submission) diff --git a/queue_services/entity-filer/src/entity_filer/utils/utils.py b/queue_services/entity-filer/src/entity_filer/utils/utils.py index 735526b7db..3b66557812 100644 --- a/queue_services/entity-filer/src/entity_filer/utils/utils.py +++ b/queue_services/entity-filer/src/entity_filer/utils/utils.py @@ -43,6 +43,7 @@ # from legal_api.reports.registrar_meta import RegistrarInfo # from legal_api.services import PdfService # from legal_api.services.minio import MinioService +# from legal_api.services.pdf_service import RegistrarStampData # from legal_api.utils.legislation_datetime import LegislationDatetime import os @@ -65,29 +66,25 @@ def get_run_version(): def replace_file_with_certified_copy( - _bytes, business, key, certify_date, file_name=None + _bytes: bytes, + key: str, +# data: RegistrarStampData ): + """Create a certified copy and replace it into Minio server.""" + raise Exception # TODO we shouldn't do this anymore -# """Create a certified copy and replace it into Minio server.""" -# open_pdf_file = io.BytesIO(_bytes) -# pdf_reader = PyPDF2.PdfFileReader(open_pdf_file) -# pdf_writer = PyPDF2.PdfFileWriter() -# pdf_writer.appendPagesFromReader(pdf_reader) -# output_original_pdf = io.BytesIO() -# pdf_writer.write(output_original_pdf) -# output_original_pdf.seek(0) - -# registrar_info = RegistrarInfo.get_registrar_info(certify_date) -# registrars_signature = registrar_info['signatureAndText'] -# pdf_service = PdfService() -# registrars_stamp = \ -# pdf_service.create_registrars_stamp(registrars_signature, -# LegislationDatetime.as_legislation_timezone(certify_date), -# business.identifier, -# file_name) -# certified_copy = pdf_service.stamp_pdf(output_original_pdf, registrars_stamp, only_first_page=True) + # open_pdf_file = io.BytesIO(_bytes) + # pdf_reader = PyPDF2.PdfFileReader(open_pdf_file) + # pdf_writer = PyPDF2.PdfFileWriter() + # pdf_writer.appendPagesFromReader(pdf_reader) + # output_original_pdf = io.BytesIO() + # pdf_writer.write(output_original_pdf) + # output_original_pdf.seek(0) + # pdf_service = PdfService() + # registrars_stamp = pdf_service.create_registrars_stamp(data) + # certified_copy = pdf_service.stamp_pdf(output_original_pdf, registrars_stamp, only_first_page=True) -# MinioService.put_file(key, certified_copy, certified_copy.getbuffer().nbytes) + # MinioService.put_file(key, certified_copy, certified_copy.getbuffer().nbytes) diff --git a/queue_services/entity-filer/tests/conftest.py b/queue_services/entity-filer/tests/conftest.py index 098038e053..8bb4da5bd8 100644 --- a/queue_services/entity-filer/tests/conftest.py +++ b/queue_services/entity-filer/tests/conftest.py @@ -236,6 +236,12 @@ def session(app, db): # pylint: disable=redefined-outer-name, invalid-name print(err) print("done") + # For those who have local databases on bare metal in local time. + # Otherwise some of the returns will come back in local time and unit tests will fail. + # The current DEV database uses UTC. + sess.execute("SET TIME ZONE 'UTC';") + sess.commit() + # establish a SAVEPOINT just before beginning the test # (http://docs.sqlalchemy.org/en/latest/orm/session_transaction.html#using-savepoint) sess.begin_nested() diff --git a/queue_services/entity-filer/tests/unit/filing_processors/test_agm_location_change.py b/queue_services/entity-filer/tests/unit/filing_processors/test_agm_location_change.py new file mode 100644 index 0000000000..0e197c30f2 --- /dev/null +++ b/queue_services/entity-filer/tests/unit/filing_processors/test_agm_location_change.py @@ -0,0 +1,54 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Unit Tests for the agm location change filing.""" +import copy +import random + +from business_model import Filing +from registry_schemas.example_data import AGM_LOCATION_CHANGE, FILING_HEADER + +from entity_filer.resources.worker import process_filing +from entity_filer.resources.worker import FilingMessage +from tests.unit import create_business, create_filing + + +def test_worker_agm_location_change(app, session, mocker): + """Assert that the agm location change object is correctly populated to model objects.""" + identifier = "BC1234567" + business = create_business(identifier, legal_type="BC") + + filing_json = copy.deepcopy(FILING_HEADER) + filing_json["filing"]["business"]["identifier"] = identifier + filing_json["filing"]["agmLocationChange"] = copy.deepcopy(AGM_LOCATION_CHANGE) + + payment_id = str(random.SystemRandom().getrandbits(0x58)) + filing = create_filing(payment_id, filing_json, business_id=business.id) + + filing_msg = FilingMessage(filing_identifier=filing.id) + + # mock out the email sender and event publishing + # mocker.patch("entity_filer.worker.publish_email_message", return_value=None) + # mocker.patch("entity_filer.worker.publish_event", return_value=None) + # Test + process_filing(filing_msg) + + # Check outcome + final_filing = Filing.find_by_id(filing.id) + assert final_filing.id + assert final_filing.meta_data + + agm_location_change = final_filing.meta_data.get("agmLocationChange") + assert filing_json["filing"]["agmLocationChange"]["year"] == agm_location_change.get("year") + assert filing_json["filing"]["agmLocationChange"]["agmLocation"] == agm_location_change.get("agmLocation") + assert filing_json["filing"]["agmLocationChange"]["reason"] == agm_location_change.get("reason") diff --git a/queue_services/entity-filer/tests/unit/filing_processors/test_alteration.py b/queue_services/entity-filer/tests/unit/filing_processors/test_alteration.py index 5c44a58d51..aeb35d7241 100644 --- a/queue_services/entity-filer/tests/unit/filing_processors/test_alteration.py +++ b/queue_services/entity-filer/tests/unit/filing_processors/test_alteration.py @@ -278,7 +278,7 @@ def test_alteration_coop_rules_and_memorandum(app, session): payment_id = str(random.SystemRandom().getrandbits(0x58)) filing_submission = create_filing( - payment_id, alteration_filing, business_id=business.id + payment_id, alteration_filing, business_id=business.id, filing_date=datetime.utcnow() ) filing_meta = FilingMeta() diff --git a/queue_services/entity-filer/tests/unit/filing_processors/test_incorporation_filing.py b/queue_services/entity-filer/tests/unit/filing_processors/test_incorporation_filing.py index 34ba8623f1..98206a7c59 100644 --- a/queue_services/entity-filer/tests/unit/filing_processors/test_incorporation_filing.py +++ b/queue_services/entity-filer/tests/unit/filing_processors/test_incorporation_filing.py @@ -244,10 +244,12 @@ def test_incorporation_filing_process_no_nr( ("too big number", "12345678", None), ], ) -def test_get_next_corp_num(requests_mock, app, test_name, response, expected): +def test_get_next_corp_num(requests_mock, mocker, app, test_name, response, expected): """Assert that the corpnum is the correct format.""" from flask import current_app + mocker.patch("legal_api.services.bootstrap.AccountService.get_bearer_token", return_value='') + with app.app_context(): current_app.config["COLIN_API"] = "http://localhost" requests_mock.post( diff --git a/queue_services/entity-filer/tests/unit/worker/test_agm_extension.py b/queue_services/entity-filer/tests/unit/worker/test_agm_extension.py new file mode 100644 index 0000000000..a4d0b949fe --- /dev/null +++ b/queue_services/entity-filer/tests/unit/worker/test_agm_extension.py @@ -0,0 +1,91 @@ +# Copyright © 2023 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Unit Tests for the agm extension filing.""" +import copy +import random + +import pytest +from business_model import Filing +from registry_schemas.example_data import AGM_EXTENSION, FILING_HEADER + +from entity_filer.resources.worker import process_filing +from entity_filer.resources.worker import FilingMessage +from tests.unit import create_business, create_filing + + +@pytest.mark.parametrize( + "test_name", + [ + ("general"), ("first_agm_year"), ("more_extension"), ("final_extension") + ] +) +def test_worker_agm_extension(app, session, mocker, test_name): + """Assert that the agm extension object is correctly populated to model objects.""" + identifier = "BC1234567" + business = create_business(identifier, legal_type="BC") + + filing_json = copy.deepcopy(FILING_HEADER) + filing_json["filing"]["business"]["identifier"] = identifier + filing_json["filing"]["agmExtension"] = copy.deepcopy(AGM_EXTENSION) + + if test_name == "first_agm_year": + del filing_json["filing"]["agmExtension"]["prevAgmRefDate"] + + if test_name != "more_extension": + del filing_json["filing"]["agmExtension"]["expireDateCurrExt"] + + if test_name == "final_extension": + filing_json["filing"]["agmExtension"]["totalApprovedExt"] = 12 + else: + filing_json["filing"]["agmExtension"]["totalApprovedExt"] = 6 + + payment_id = str(random.SystemRandom().getrandbits(0x58)) + filing = create_filing(payment_id, filing_json, business_id=business.id) + + filing_msg = FilingMessage(filing_identifier=filing.id) + + # mock out the email sender and event publishing + # mocker.patch("entity_filer.worker.publish_email_message", return_value=None) + # mocker.patch("entity_filer.worker.publish_event", return_value=None) + + # test + process_filing(filing_msg) + + # check outcome + final_filing = Filing.find_by_id(filing.id) + assert final_filing.id + assert final_filing.meta_data + + agm_extension = final_filing.meta_data.get("agmExtension") + assert agm_extension + assert filing_json["filing"]["agmExtension"]["year"] == agm_extension.get("year") + assert filing_json["filing"]["agmExtension"]["isFirstAgm"] == agm_extension.get("isFirstAgm") + assert filing_json["filing"]["agmExtension"]["extReqForAgmYear"] == agm_extension.get("extReqForAgmYear") + assert filing_json["filing"]["agmExtension"]["totalApprovedExt"] == agm_extension.get("totalApprovedExt") + assert filing_json["filing"]["agmExtension"]["extensionDuration"] == agm_extension.get("extensionDuration") + + if test_name == "first_agm_year": + assert agm_extension.get("prevAgmRefDate") is None + else: + assert filing_json["filing"]["agmExtension"]["prevAgmRefDate"] == agm_extension.get("prevAgmRefDate") + + if test_name == "more_extension": + assert filing_json["filing"]["agmExtension"]["expireDateCurrExt"] == agm_extension.get("expireDateCurrExt") + else: + assert agm_extension.get("expireDateCurrExt") is None + + if test_name == "final_extension": + assert agm_extension.get("isFinalExtension") is True + else: + assert agm_extension.get("isFinalExtension") is False diff --git a/queue_services/entity-filer/tests/unit/worker/test_amalgamation_application.py b/queue_services/entity-filer/tests/unit/worker/test_amalgamation_application.py new file mode 100644 index 0000000000..300fdf50bb --- /dev/null +++ b/queue_services/entity-filer/tests/unit/worker/test_amalgamation_application.py @@ -0,0 +1,100 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Unit Tests for the Amalgamation application filing.""" + +import copy +from datetime import datetime, timezone +from http import HTTPStatus +from unittest.mock import patch + +import pytest +from business_model import Amalgamation, Filing, LegalEntity +from registry_schemas.example_data import AMALGAMATION_APPLICATION + +from entity_filer.filing_processors.filing_components import legal_entity_info +from entity_filer.resources.worker import process_filing +from entity_filer.resources.worker import FilingMessage +from tests.unit import create_entity, create_filing + + +def test_amalgamation_application_process(app, session): + """Assert that the amalgamation application object is correctly populated to model objects.""" + filing_type = "amalgamationApplication" + amalgamating_identifier_1 = "BC9891234" + amalgamating_identifier_2 = "BC9891235" + nr_identifier = "NR 1234567" + next_corp_num = "BC0001095" + + amalgamating_business_1_id = create_entity(amalgamating_identifier_1, "BC", "amalgamating business 1").id + amalgamating_business_2_id = create_entity(amalgamating_identifier_2, "BC", "amalgamating business 2").id + + filing = {"filing": {}} + filing["filing"]["header"] = {"name": filing_type, "date": "2019-04-08", + "certifiedBy": "full name", "email": "no_one@never.get", "filingId": 1} + filing["filing"][filing_type] = copy.deepcopy(AMALGAMATION_APPLICATION) + filing["filing"][filing_type]["amalgamatingBusinesses"] = [ + { + "role": "amalgamating", + "identifier": amalgamating_identifier_1 + }, + { + "role": "amalgamating", + "identifier": amalgamating_identifier_2 + } + ] + + filing["filing"][filing_type]["nameRequest"]["nrNumber"] = nr_identifier + + filing_rec = create_filing("123", filing) + effective_date = datetime.now(timezone.utc) + filing_rec.effective_date = effective_date + filing_rec.save() + + # test + filing_msg = FilingMessage(filing_identifier=filing.id) + with patch.object(legal_entity_info, "get_next_corp_num", return_value=next_corp_num): + process_filing(filing_msg) + + # Assertions + filing_rec = Filing.find_by_id(filing_rec.id) + business = LegalEntity.find_by_identifier(next_corp_num) + + assert filing_rec.legal_entity_id == business.id + assert filing_rec.status == Filing.Status.COMPLETED.value + assert business.identifier + assert business.founding_date == effective_date + assert business.entity_type == filing["filing"][filing_type]["nameRequest"]["legalType"] + assert business.legal_name == filing["filing"][filing_type]["nameRequest"]["legalName"] + assert business.state == LegalEntity.State.ACTIVE + + assert len(business.share_classes.all()) == len(filing["filing"][filing_type]["shareStructure"]["shareClasses"]) + assert len(business.offices.all()) == len(filing["filing"][filing_type]["offices"]) + assert len(business.aliases.all()) == len(filing["filing"][filing_type]["nameTranslations"]) + assert business.party_roles[0].role == "director" + assert filing_rec.filing_party_roles[0].role == "completing_party" + + assert business.amalgamation + amalgamation: Amalgamation = business.amalgamation[0] + assert amalgamation.amalgamation_date == effective_date + assert amalgamation.filing_id == filing_rec.id + assert amalgamation.amalgamation_type.name == filing["filing"][filing_type]["type"] + assert amalgamation.court_approval == filing["filing"][filing_type]["courtApproval"] + + for amalgamating_business in amalgamation.amalgamating_businesses: + assert amalgamating_business.role.name == "amalgamating" + assert amalgamating_business.legal_entity_id in [amalgamating_business_1_id, amalgamating_business_2_id] + dissolved_business = LegalEntity.find_by_internal_id(amalgamating_business.legal_entity_id) + assert dissolved_business.state == LegalEntity.State.HISTORICAL + assert dissolved_business.state_filing_id == filing_rec.id + assert dissolved_business.dissolution_date == effective_date diff --git a/queue_services/entity-filer/tests/unit/worker/test_correction_special_resolution.py b/queue_services/entity-filer/tests/unit/worker/test_correction_special_resolution.py index 396f0154bf..72ec1b699c 100644 --- a/queue_services/entity-filer/tests/unit/worker/test_correction_special_resolution.py +++ b/queue_services/entity-filer/tests/unit/worker/test_correction_special_resolution.py @@ -99,24 +99,29 @@ def test_special_resolution_correction( sr_filing_msg = {"filing": {"id": sr_filing_id}} # Call the process_filing method for the original special resolution process_filing(sr_filing_msg) - - # Simulate a correction filing - correction_data = copy.deepcopy(FILING_HEADER) - correction_data["filing"]["correction"] = copy.deepcopy(correction_template) - correction_data["filing"]["header"]["name"] = "correction" - correction_data["filing"]["business"] = {"identifier": identifier} - correction_data["filing"]["correction"]["correctedFilingType"] = correct_filing_type - correction_data["filing"]["correction"]["resolution"] = "

    xxxx

    " - correction_data["filing"]["correction"]["signatory"] = { - "givenName": "Joey", - "familyName": "Doe", - "additionalName": "", - } - correction_data["filing"]["correction"]["cooperativeAssociationType"] = "HC" - # Update correction data to point to the original special resolution filing - if "correction" not in correction_data["filing"]: - correction_data["filing"]["correction"] = {} - correction_data["filing"]["correction"]["correctedFilingId"] = sr_filing_id + + if correct_filing_type == "changeOfAddress": + correction_data = copy.deepcopy(correction_template) + correction_data["filing"]["changeOfAddress"]["offices"] = {} + correction_data["filing"]["correction"]["correctedFilingId"] = sr_filing_id + else: + # Simulate a correction filing + correction_data = copy.deepcopy(FILING_HEADER) + correction_data["filing"]["correction"] = copy.deepcopy(correction_template) + correction_data["filing"]["header"]["name"] = "correction" + correction_data["filing"]["business"] = {"identifier": identifier} + correction_data["filing"]["correction"]["correctedFilingType"] = correct_filing_type + correction_data["filing"]["correction"]["resolution"] = "

    xxxx

    " + correction_data["filing"]["correction"]["signatory"] = { + "givenName": "Joey", + "familyName": "Doe", + "additionalName": "", + } + correction_data["filing"]["correction"]["cooperativeAssociationType"] = "HC" + # Update correction data to point to the original special resolution filing + if "correction" not in correction_data["filing"]: + correction_data["filing"]["correction"] = {} + correction_data["filing"]["correction"]["correctedFilingId"] = sr_filing_id correction_payment_id = str(random.SystemRandom().getrandbits(0x58)) correction_filing_id = ( create_filing(correction_payment_id, correction_data, business_id=business_id) From ad4e24afe9600c114d7bbf999ff7876f6412863d Mon Sep 17 00:00:00 2001 From: "vysakh.menon" Date: Mon, 22 Jan 2024 11:28:24 -0800 Subject: [PATCH 3/3] no message --- legal-api/src/legal_api/core/meta/filing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/legal-api/src/legal_api/core/meta/filing.py b/legal-api/src/legal_api/core/meta/filing.py index fbf8ad7856..6839ad9fea 100644 --- a/legal-api/src/legal_api/core/meta/filing.py +++ b/legal-api/src/legal_api/core/meta/filing.py @@ -72,7 +72,6 @@ class FilingTitles(str, Enum): FILINGS: Final = { -<<<<<<< HEAD "adminFreeze": {"name": "adminFreeze", "title": "Admin Freeze", "displayName": "Admin Freeze", "code": "NOFEE"}, "affidavit": {"name": "affidavit", "title": "Affidavit", "codes": {"CP": "AFDVT"}}, "agmExtension": {