diff --git a/application/.coveragerc b/.coveragerc similarity index 61% rename from application/.coveragerc rename to .coveragerc index 345ef37e6..f52bf74da 100644 --- a/application/.coveragerc +++ b/.coveragerc @@ -4,4 +4,6 @@ omit = **/__init__.py tests/* **/tests/* + application/dos_db_handler/*.py + **/conftest.py branch = True diff --git a/.editorconfig b/.editorconfig index 2ea900caf..0648e5a87 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,3 +16,6 @@ indent_style = tab [*.py] indent_size = 4 + +[*.md] +indent_size = unset diff --git a/.github/PULL_REQUEST_TEMPLATE/release_pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/release_pull_request_template.md new file mode 100644 index 000000000..828bc9658 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/release_pull_request_template.md @@ -0,0 +1,5 @@ +# Release Branch Pull Request + +## Description of Changes + +Please include a summary of the change diff --git a/.github/PULL_REQUEST_TEMPLATE/task_pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/task_pull_request_template.md new file mode 100644 index 000000000..6f4cb2ab4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/task_pull_request_template.md @@ -0,0 +1,26 @@ +# Task Branch Pull Request + +**** + +## Description of Changes + +Please include a summary of the change + +## Type of change + +Delete not appropriate + +- Bug fix (non-breaking change which fixes an issue) +- New feature (non-breaking change which adds functionality) +- Breaking change (fix or feature that would cause existing functionality to not work as expected) +- Refactoring (non-breaking change which improves the structure of the code) + +## Development Checklist + +- [ ] I have performed a self-review of my own code +- [ ] Tests have added that prove my fix is effective or that my feature works (Integration tests) +- [ ] I have updated Dependabot to include my changes (if applicable) + +## Code Reviewer Checklist + +- [ ] I can confirm the changes have been tested or approved by a tester diff --git a/.github/PULL_REQUEST_TEMPLATE/test_pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/test_pull_request_template.md new file mode 100644 index 000000000..4b1701ac0 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/test_pull_request_template.md @@ -0,0 +1,22 @@ +# Test Branch Pull Request + +## What branch do these tests check? + +- + +## Description of changes/tests + +Why do these tests need to exist?/When should the test be run? + +## Development Checklist + +- [ ] The tests are tagged correctly +- [ ] The tests will be run in the development pipeline +- [ ] The tests are stable and pass +- [ ] I have used reusable functions and classes where possible + +## Code Reviewer Checklist + +- [ ] I am confident the tests are stable and have passed +- [ ] I am confident the tests will be run in the development pipeline +- [ ] I believe the tests developed in a way which makes them reusable and maintainable diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..7ad7043be --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,34 @@ +# Security + +NHS England takes security and the protection of private data extremely seriously. If you believe you have found a vulnerability or other issue which has compromised or could compromise the security of any of our systems and/or private data managed by our systems, please do not hesitate to contact us using the methods outlined below. + +## Table of Contents + +- [Security](#security) + - [Table of Contents](#table-of-contents) + - [Reporting a vulnerability](#reporting-a-vulnerability) + - [Email](#email) + - [NCSC](#ncsc) + - [General Security Enquiries](#general-security-enquiries) + +## Reporting a vulnerability + +Please note, email is our preferred method of receiving reports. + +### Email + +If you wish to notify us of a vulnerability via email, please include detailed information on the nature of the vulnerability and any steps required to reproduce it. + +You can reach us at: + +- [cybersecurity@nhs.net](cybersecurity@nhs.net) + +### NCSC + +You can send your report to the National Cyber Security Centre, who will assess your report and pass it on to NHS England if necessary. + +You can report vulnerabilities here: [https://www.ncsc.gov.uk/information/vulnerability-reporting](https://www.ncsc.gov.uk/information/vulnerability-reporting) + +## General Security Enquiries + +If you have general enquiries regarding our cyber security, please reach out to us at [cybersecurity@nhs.net](cybersecurity@nhs.net) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..38f8832aa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,66 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + target-branch: "develop" + + # Maintain dependencies for Python (Works recursively in application directories) + - package-ecosystem: "pip" + directory: "/application" + schedule: + interval: "monthly" + target-branch: "develop" + versioning-strategy: increase-if-necessary + + # Docker Dependencies + - package-ecosystem: "docker" + directory: "/build/docker/lambda" + schedule: + interval: "daily" + target-branch: "develop" + + - package-ecosystem: "docker" + directory: "/build/docker/tester" + schedule: + interval: "daily" + target-branch: "develop" + + # Terraform Dependencies + - package-ecosystem: "terraform" + directory: "/infrastructure/modules/s3" + schedule: + interval: "monthly" + target-branch: "develop" + + - package-ecosystem: "terraform" + directory: "/infrastructure/stacks/api-key" + schedule: + interval: "monthly" + target-branch: "develop" + + - package-ecosystem: "terraform" + directory: "/infrastructure/stacks/blue-green-link" + schedule: + interval: "monthly" + target-branch: "develop" + + - package-ecosystem: "terraform" + directory: "/infrastructure/stacks/shared-resources" + schedule: + interval: "monthly" + target-branch: "develop" + + - package-ecosystem: "terraform" + directory: "/infrastructure/stacks/application" + schedule: + interval: "monthly" + target-branch: "develop" + + - package-ecosystem: "terraform" + directory: "/infrastructure/stacks/development-and-deployment-tools" + schedule: + interval: "monthly" + target-branch: "develop" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 273965fec..3ff512f6a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,45 +1,3 @@ -## Link to JIRA Ticket +# Warning -- - -## Description - -Please include a summary of the change - -### Noteworthy Changes - -- These are changes the reviewer should look out for - -## Type of change - -Delete not appropriate - -- Bug fix (non-breaking change which fixes an issue) -- New feature (non-breaking change which adds functionality) -- Breaking change (fix or feature that would cause existing functionality to not work as expected) -- This change requires a documentation update - -## Testing - -Please tick the testing that has been completed - -- [ ] Unit tests -- [ ] Integration tests - -## Developer Checklist - -- [ ] I have performed a self-review of my own code -- [ ] I have run the [code formatting checks](../README.md#code-quality) -- [ ] I have run the [code quality checks](../README.md#code-quality) -- [ ] New code meets [standards](https://nhsd-confluence.digital.nhs.uk/display/DI/DI+Ways+of+Working) agreed by the team -- [ ] Unit test code coverage is at or above 80% -- [ ] New and existing unit tests pass locally with my changes -- [ ] Tests have added that prove my fix is effective or that my feature works (Integration tests) -- [ ] I have made corresponding changes to the documentation -- [ ] I have cleaned down my environment (if created) - -## Code Reviewer Checklist - -- [ ] I have run the unit tests and they run correctly -- [ ] I can confirm the changes have been tested or approved by a tester -- [ ] I can confirm no remaining infrastructure is left over from this branch +Please don't modify this description yet it will be populated once you create the pull request. diff --git a/.github/workflows/check-pull-request-checklist.yml b/.github/workflows/check-pull-request-checklist.yml new file mode 100644 index 000000000..74d06a1a6 --- /dev/null +++ b/.github/workflows/check-pull-request-checklist.yml @@ -0,0 +1,19 @@ +name: "Check Pull Request Checklist" + +on: + pull_request: + types: [opened, ready_for_review, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + pull-request-checklist: + runs-on: ubuntu-latest + steps: + - uses: mheap/require-checklist-action@v2 + with: + requireChecklist: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 432818848..797e5f3e0 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,38 +1,87 @@ name: "Check code format and quality" + on: push: - branches: [master] + branches: [develop, main] pull_request: - types: [opened, synchronize, reopened] + types: [opened, ready_for_review, synchronize, reopened] + +permissions: + contents: read + jobs: check-code-quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Check text files format run: | build/automation/etc/githooks/scripts/editorconfig-pre-commit.sh - - name: Check Python files format + - name: Tester Build run: | - make python-linting - - name: Check Terraform files format + make tester-build + - name: Check Python Unit Test Coverage run: | - build/automation/etc/githooks/scripts/terraform-format-pre-commit.sh - - name: Create coverage report - run: | - make tester-build coverage-report + make coverage-report - uses: sonarsource/sonarcloud-github-action@master # SEE: https://github.com/SonarSource/sonarcloud-github-action env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: - projectBaseDir: ./application + projectBaseDir: . args: > + -Dsonar.sources=application,infrastructure,scripts,test,build/docker -Dsonar.organization=nhsd-exeter -Dsonar.projectKey=uec-dos-int - -Dsonar.coverage.exclusions=tests/**,**/tests/** + -Dsonar.coverage.exclusions=tests/**,**/tests/**,infrastructure,application/dos_db_handler/**,test/**,scripts/**,application/conftest.py -Dsonar.python.coverage.reportPaths=coverage.xml - -Dsonar.python.version=3.9 + -Dsonar.python.version=3.12 + -Dsonar.exclusions=application/**/tests/** + + check-markdown-code-quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check Markdown format + uses: DavidAnson/markdownlint-cli2-action@v13 + with: + config: .github/workflows/configs/markdownlint/.markdownlint.json + - name: Check Markdown links + uses: gaurav-nelson/github-action-markdown-link-check@v1 + with: + use-quiet-mode: "yes" + config-file: .github/workflows/configs/markdownlint/markdown-check-links.json + base-branch: develop + check-modified-files-only: "yes" + + check-prose: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check prose + uses: errata-ai/vale-action@reviewdog + with: + fail_on_error: true + + check-python-code-quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Tester Build + run: | + make tester-build + - name: Run Python Linting & Formatting + run: | + make python-run-ruff-checks + - name: Check for Python Dead Code + run: | + make python-check-dead-code diff --git a/.github/workflows/code-secrets.yml b/.github/workflows/code-secrets.yml new file mode 100644 index 000000000..ae56071ab --- /dev/null +++ b/.github/workflows/code-secrets.yml @@ -0,0 +1,18 @@ +name: "Check code for Secrets" + +on: push + +permissions: + contents: read + +jobs: + check-code-secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check if code contains any secrets + run: make git-config git-secrets-scan-repo-files + - name: Checkov Secret Scanner + run: make checkov-secret-scanning diff --git a/.github/workflows/code-security.yml b/.github/workflows/code-security.yml index e79a3dfa8..6d20b233c 100644 --- a/.github/workflows/code-security.yml +++ b/.github/workflows/code-security.yml @@ -1,14 +1,26 @@ -name: "Check code for secrets" -on: push +name: "Check code for Security Vulnerabilities" + +on: + push: + branches: [develop, master] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + jobs: check-code-security: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Check if code contains any secrets - run: | - make \ - git-config \ - git-secrets-scan-repo-files + - name: Check if code contains any Terraform Security Vulnerabilities + run: make terraform-security + - name: Checkov Security and Best Practices - Docker + run: make -s docker-best-practices + - name: Checkov Security and Best Practices - Terraform + run: make -s terraform-best-practices + - name: Checkov Security and Best Practices - Github Actions + run: make -s github-actions-best-practices diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..787ba81b8 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,28 @@ +name: "Code QL Analysis" + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + - cron: "30 1 * * 0" + +permissions: + contents: read + +jobs: + CodeQL-Build: + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: python + queries: security-and-quality + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/configs/markdownlint/.markdownlint.json b/.github/workflows/configs/markdownlint/.markdownlint.json new file mode 100644 index 000000000..91cf9e295 --- /dev/null +++ b/.github/workflows/configs/markdownlint/.markdownlint.json @@ -0,0 +1,9 @@ +{ + "default": true, + "MD013": { + "line_length": 500 + }, + "MD024": { + "siblings_only": true + } +} diff --git a/.github/workflows/configs/markdownlint/markdown-check-links.json b/.github/workflows/configs/markdownlint/markdown-check-links.json new file mode 100644 index 000000000..1e005cadb --- /dev/null +++ b/.github/workflows/configs/markdownlint/markdown-check-links.json @@ -0,0 +1,21 @@ +{ + "aliveStatusCodes": [ + 200, + 403 + ], + "ignorePatterns": [ + { + "pattern": "^https://nhsd-confluence.digital.nhs.uk/" + }, + { + "pattern": "^https://nhsd-jira.digital.nhs.uk/" + }, + { + "pattern": "^https://ddc-exeter-data-clone-prod-ddc-data-clone.k8s-prod.texasplatform.uk/" + }, + { + "pattern": "^*@nhs.net" + } + ], + "timeout": "5s" +} diff --git a/.github/workflows/configs/vale/styles/.gitignore b/.github/workflows/configs/vale/styles/.gitignore new file mode 100644 index 000000000..e7738f3f0 --- /dev/null +++ b/.github/workflows/configs/vale/styles/.gitignore @@ -0,0 +1,2 @@ +write-good +Google diff --git a/.github/workflows/configs/vale/styles/config/vocabularies/words/accept.txt b/.github/workflows/configs/vale/styles/config/vocabularies/words/accept.txt new file mode 100644 index 000000000..85dd11711 --- /dev/null +++ b/.github/workflows/configs/vale/styles/config/vocabularies/words/accept.txt @@ -0,0 +1,45 @@ +// Application Names +Bitwarden +Cyber +Dependabot +Gitleaks +Grype +OAuth +Octokit +Podman +Python +Syft +Terraform +Trufflehog +bot +idempotence +toolchain +GPG +Splunk +CodeBuild +CodePipeline +Xcode +Tunnelblick +// Environments +dev +perf +perf2 +nonprod +prod +NonProd +Prod +mgmt +MGMT +// Development/Deployment Terms +adhoc +healthcheck +runbook +Runbook +undeploy +Undeploy +Onboarding +onboarding +// Miscellaneous +bbbbbb +gggggg +[A-Z]+s diff --git a/.github/workflows/configs/vale/styles/config/vocabularies/words/reject.txt b/.github/workflows/configs/vale/styles/config/vocabularies/words/reject.txt new file mode 100644 index 000000000..fdc793e78 --- /dev/null +++ b/.github/workflows/configs/vale/styles/config/vocabularies/words/reject.txt @@ -0,0 +1 @@ +python diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..2167bb12c --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,14 @@ +name: "Dependency Review" +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: "Checkout Repository" + uses: actions/checkout@v4 + - name: "Dependency Review" + uses: actions/dependency-review-action@v3 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5fb82e5f1..1716461c2 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -1,16 +1,24 @@ name: "Check pull request title, branch name and commit message" + on: pull_request: types: [opened, edited, synchronize, reopened] + branches-ignore: + - "release/**" + - "main" + +permissions: + contents: read + jobs: check-pull-request: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Check pull request title - if: github.event_name == 'pull_request' + if: ${{ github.event_name == 'pull_request' && !contains(github.head_ref, 'release') }} run: | [ $(make git-check-if-pull-request-title-is-correct BUILD_BRANCH="${{ github.head_ref }}" PULL_REQUEST_TITLE="${{ github.event.pull_request.title }}") = true ] && \ exit 0 || make show-configuration | grep -E 'GIT_COMMIT_MESSAGE_|GIT_PULL_REQUEST_' && printf "\nPlease, for more details see the 'build/automation/test/git.test.mk' file.\n" && exit 1 @@ -31,6 +39,7 @@ jobs: [ $(make git-check-if-commit-msg-is-correct BUILD_BRANCH="${{ github.head_ref }}" BUILD_COMMIT_MESSAGE="${{ env.BUILD_COMMIT_MESSAGE }}") = true ] && \ exit 0 || make show-configuration | grep GIT_COMMIT_MESSAGE_ && printf "\nPlease, for more details see the 'build/automation/test/git.test.mk' file.\n" && exit 1 - name: Check branch name + if: ${{ github.event_name == 'pull_request' && !contains(github.head_ref, 'release') }} run: | [ $(make git-check-if-branch-name-is-correct BUILD_BRANCH="${{ github.head_ref }}") = true ] && \ exit 0 || make show-configuration | grep GIT_BRANCH_ && printf "\nPlease, for more details see the 'build/automation/test/git.test.mk' file.\n" && exit 1 diff --git a/.github/workflows/update-pull-request-description.yml b/.github/workflows/update-pull-request-description.yml new file mode 100644 index 000000000..430de105c --- /dev/null +++ b/.github/workflows/update-pull-request-description.yml @@ -0,0 +1,44 @@ +name: "Update Pull Request description with Template" + +on: + pull_request: + types: [opened] + branches-ignore: + - "dependabot/**" + +permissions: + contents: read + pull-requests: write + +jobs: + update-pull-request: + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Get Branch Name + uses: mdecoleman/pr-branch-name@2.0.0 + id: branch_name + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Get branch number + run: echo "BRANCH_NUMBER=$(echo ${{ steps.branch_name.outputs.branch }} | grep -Eo '[A-Za-z]{2,5}-[0-9]{1,5}')" >> $GITHUB_ENV + - name: Get branch designator + run: echo "BRANCH_DESIGNATOR=$(echo ${{ steps.branch_name.outputs.branch }} | sed 's|/.*||')" >> $GITHUB_ENV + - name: Get template name + run: echo "TEMPLATE_NAME=$(cat .github/PULL_REQUEST_TEMPLATE/${{ env.BRANCH_DESIGNATOR }}_pull_request_template.md > /dev/null 2>&1 && echo ${{ env.BRANCH_DESIGNATOR }}_pull_request_template.md || echo task_pull_request_template.md)" >> $GITHUB_ENV + - name: Render Pull Request template + id: template + uses: chuhlomin/render-template@v1.5 + with: + template: .github/PULL_REQUEST_TEMPLATE/${{ env.TEMPLATE_NAME }} + vars: | + BRANCH_NUMBER: ${{ env.BRANCH_NUMBER }} + BRANCH_DESIGNATOR: ${{ env.BRANCH_DESIGNATOR }} + - uses: tzkhan/pr-update-action@v2 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + head-branch-regex: "${{ env.BRANCH_DESIGNATOR }}/.*" + body-template: ${{ steps.template.outputs.result }} + body-update-action: "replace" diff --git a/.gitignore b/.gitignore index 0b9163fb3..cc8487f13 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,11 @@ .nvmrc .python-version .scannerwork +.vscode *.code-workspace !project.code-workspace +.coverage +*.xml +jenkins.cookies +html +__pycache__/ diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 000000000..809ef3830 --- /dev/null +++ b/.vale.ini @@ -0,0 +1,6 @@ +StylesPath = .github/workflows/configs/vale/styles +MinAlertLevel = warning +Vocab = words + +[README.md] +BasedOnStyles = Vale diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..e460c205d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,17 @@ +{ + "recommendations": [ + "ban.spellright", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "johnpapa.vscode-peacock", + "streetsidesoftware.code-spell-checker", + "vscode-icons-team.vscode-icons", + "yzhang.dictionary-completion", + "yzhang.markdown-all-in-one", + "charliermarsh.ruff" + ], + "unwantedRecommendations": [ + "googlecloudtools.cloudcode", + "ms-azuretools.vscode-azurefunctions" + ] +} diff --git a/LICENCE.md b/LICENCE.md new file mode 100644 index 000000000..0ead43267 --- /dev/null +++ b/LICENCE.md @@ -0,0 +1,21 @@ +# MIT Licence + +Copyright (c) 2023 Crown Copyright NHS England. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index f0bc80a38..be1b8d23a 100644 --- a/Makefile +++ b/Makefile @@ -5,441 +5,243 @@ include $(abspath $(PROJECT_DIR)/build/automation/init.mk) # Development workflow targets setup: project-config # Set up project - make serverless-build make tester-build -build: # Build lambdas - make -s event-sender-build \ - event-processor-build \ - fifo-dlq-handler-build \ - cr-fifo-dlq-handler-build \ - orchestrator-build \ - slack-messenger-build \ - authoriser-build \ - dos-api-gateway-build \ - event-replay-build \ - test-db-checker-handler-build - -start: # Stop project - make project-start +container-check: # Check the container management tool - can be set with: "export DOCKER_CMD=`which podman`" or "export DOCKER_CMD=`which docker`" + echo $(DOCKER_CMD) -stop: # Stop project - make project-stop +build: # Build lambdas + for IMAGE_NAME in $$(echo $(PROJECT_LAMBDAS_LIST) | tr "," "\n"); do + make -s build-lambda GENERIC_IMAGE_NAME=lambda NAME=$$IMAGE_NAME + done -restart: stop start # Restart project +build-lambda: ### Build lambda docker image - mandatory: NAME + UNDERSCORE_LAMBDA_NAME=$$(echo $(NAME) | tr '-' '_') + cp -f $(APPLICATION_DIR)/$$UNDERSCORE_LAMBDA_NAME/requirements.txt $(DOCKER_DIR)/lambda/assets/requirements.txt + cd $(APPLICATION_DIR) + tar -czf $(DOCKER_DIR)/lambda/assets/app.tar.gz \ + --exclude=tests $$UNDERSCORE_LAMBDA_NAME common/*.py __init__.py > /dev/null 2>&1 + cd $(PROJECT_DIR) + make -s docker-image GENERIC_IMAGE_NAME=lambda CMD=$$UNDERSCORE_LAMBDA_NAME.$$UNDERSCORE_LAMBDA_NAME.lambda_handler BUILD_OPTS="--platform=linux/arm64" + rm -f $(DOCKER_DIR)/lambda/assets/*.tar.gz $(DOCKER_DIR)/lambda/assets/*.txt -log: project-log # Show project logs +build-and-push: # Build lambda docker images and pushes them to ECR + for IMAGE_NAME in $$(echo $(PROJECT_LAMBDAS_LIST) | tr "," "\n"); do + make -s build-lambda GENERIC_IMAGE_NAME=lambda NAME=$$IMAGE_NAME + make -s docker-push NAME=$$IMAGE_NAME + done deploy: # Deploys whole project - mandatory: PROFILE - if [ "$(PROFILE)" == "task" ] || [ "$(PROFILE)" == "dev" ]; then - make mock-dos-api-gateway-deployment - fi - eval "$$(make -s populate-deployment-variables)" - make terraform-apply-auto-approve STACKS=api-key,appconfig,before-lambda-deployment - make serverless-deploy - make terraform-apply-auto-approve STACKS=after-lambda-deployment + eval "$$(make -s populate-tagging-variables)" + make terraform-apply-auto-approve STACKS=api-key,shared-resources,application,blue-green-link undeploy: # Undeploys whole project - mandatory: PROFILE - make terraform-destroy-auto-approve STACKS=after-lambda-deployment - make serverless-remove VERSION="any" DB_PASSWORD="any" DB_SERVER="any" DB_USER_NAME="any" SLACK_WEBHOOK_URL="any" - make terraform-destroy-auto-approve STACKS=before-lambda-deployment,appconfig - if [ "$(PROFILE)" == "task" ] || [ "$(PROFILE)" == "dev" ] || [ "$(PROFILE)" == "perf" ]; then + eval "$$(make -s populate-tagging-variables)" + make terraform-destroy-auto-approve STACKS=blue-green-link,application,shared-resources VERSION=any + if [ "$(PROFILE)" != "live" ]; then make terraform-destroy-auto-approve STACKS=api-key fi - if [ "$(PROFILE)" == "task" ] || [ "$(PROFILE)" == "dev" ]; then - make terraform-destroy-auto-approve STACKS=dos-api-gateway-mock - fi build-and-deploy: # Builds and Deploys whole project - mandatory: PROFILE - make build VERSION=$(BUILD_TAG) - make push-images VERSION=$(BUILD_TAG) + make build-and-push VERSION=$(BUILD_TAG) make deploy VERSION=$(BUILD_TAG) -populate-deployment-variables: - echo "export DB_SERVER=$$(make -s aws-rds-describe-instance-value DB_INSTANCE=$(DB_SERVER_NAME) KEY_DOT_PATH=Endpoint.Address)" - echo "export DB_USER_NAME=$$(make -s secret-get-existing-value NAME=$(DB_USER_NAME_SECRET_NAME) KEY=$(DB_USER_NAME_SECRET_KEY))" - echo "export SLACK_WEBHOOK_URL=$$(make -s secret-get-existing-value NAME=$(SLACK_WEBHOOK_SECRET_NAME) KEY=$(SLACK_WEBHOOK_SECRET_KEY))" - -unit-test-local: - pyenv local .venv - pip install -r application/requirements-dev.txt -r application/event_processor/requirements.txt -r application/event_replay/requirements.txt -r application/event_sender/requirements.txt -r application/fifo_dlq_handler/requirements.txt - cd application - python -m pytest --junitxml=./testresults.xml --cov-report term-missing --cov-report xml:coverage.xml --cov=. -vv +populate-tagging-variables: + echo "unset AWS_PROFILE" + DEPLOYMENT_SECRETS=$$(make -s secret-get-existing-value NAME=$(DEPLOYMENT_SECRETS)) + echo "export TF_VAR_service_category=$$(echo $$DEPLOYMENT_SECRETS | jq -r '.$(SERVICE_CATEGORY_KEY)')" + echo "export TF_VAR_data_classification=$$(echo $$DEPLOYMENT_SECRETS | jq -r '.$(DATA_CLASSIFICATION_KEY)')" + echo "export TF_VAR_distribution_list=$$(echo $$DEPLOYMENT_SECRETS | jq -r '.$(DISTRIBUTION_LIST_KEY)')" unit-test: - make -s docker-run-tools \ - IMAGE=$$(make _docker-get-reg)/tester:latest \ - CMD="python -m pytest --junitxml=./testresults.xml --cov-report term-missing --cov-report xml:coverage.xml --cov=. -vv" \ - DIR=./application \ - ARGS=" \ - -e POWERTOOLS_LOG_DEDUPLICATION_DISABLED="1" \ - --volume $(APPLICATION_DIR)/authoriser:/tmp/.packages/authoriser \ - --volume $(APPLICATION_DIR)/dos_api_gateway:/tmp/.packages/dos_api_gateway \ - --volume $(APPLICATION_DIR)/event_processor:/tmp/.packages/event_processor \ - --volume $(APPLICATION_DIR)/event_sender:/tmp/.packages/event_sender \ - --volume $(APPLICATION_DIR)/fifo_dlq_handler:/tmp/.packages/fifo_dlq_handler \ - --volume $(APPLICATION_DIR)/cr_fifo_dlq_handler:/tmp/.packages/cr_fifo_dlq_handler \ - --volume $(APPLICATION_DIR)/event_replay:/tmp/.packages/event_replay \ - --volume $(APPLICATION_DIR)/test_db_checker_handler:/tmp/.packages/test_db_checker_handler \ - --volume $(APPLICATION_DIR)/orchestrator:/tmp/.packages/orchestrator \ - --volume $(APPLICATION_DIR)/slack_messenger:/tmp/.packages/slack_messenger \ - " + make -s docker-run-tester \ + CMD="python -m pytest application --junitxml=./testresults.xml --cov-report term-missing --cov-report xml:coverage.xml --cov=application -vv" \ + ARGS=$(UNIT_TEST_ARGS) coverage-report: # Runs whole project coverage unit tests - make -s python-code-coverage DIR=$(APPLICATION_DIR_REL) \ + make -s python-code-coverage CMD="-m pytest application" DIR=/ \ IMAGE=$$(make _docker-get-reg)/tester:latest \ - ARGS=" \ + ARGS=$(UNIT_TEST_ARGS) + +coverage-html: + make -s docker-run-tools CMD="coverage html" DIR=/ \ + IMAGE=$$(make _docker-get-reg)/tester:latest \ + ARGS=$(UNIT_TEST_ARGS) + +UNIT_TEST_ARGS=" \ -e POWERTOOLS_LOG_DEDUPLICATION_DISABLED="1" \ - --volume $(APPLICATION_DIR)/authoriser:/tmp/.packages/authoriser \ - --volume $(APPLICATION_DIR)/dos_api_gateway:/tmp/.packages/dos_api_gateway \ - --volume $(APPLICATION_DIR)/event_processor:/tmp/.packages/event_processor \ - --volume $(APPLICATION_DIR)/event_sender:/tmp/.packages/event_sender \ - --volume $(APPLICATION_DIR)/fifo_dlq_handler:/tmp/.packages/fifo_dlq_handler \ - --volume $(APPLICATION_DIR)/cr_fifo_dlq_handler:/tmp/.packages/cr_fifo_dlq_handler \ + --volume $(APPLICATION_DIR)/common:/tmp/.packages/common \ + --volume $(APPLICATION_DIR)/change_event_dlq_handler:/tmp/.packages/change_event_dlq_handler \ + --volume $(APPLICATION_DIR)/dos_db_handler:/tmp/.packages/dos_db_handler \ + --volume $(APPLICATION_DIR)/dos_db_update_dlq_handler:/tmp/.packages/dos_db_update_dlq_handler \ --volume $(APPLICATION_DIR)/event_replay:/tmp/.packages/event_replay \ - --volume $(APPLICATION_DIR)/test_db_checker_handler:/tmp/.packages/test_db_checker_handler \ - --volume $(APPLICATION_DIR)/orchestrator:/tmp/.packages/orchestrator \ + --volume $(APPLICATION_DIR)/ingest_change_event:/tmp/.packages/ingest_change_event \ + --volume $(APPLICATION_DIR)/send_email:/tmp/.packages/send_email \ + --volume $(APPLICATION_DIR)/service_matcher:/tmp/.packages/service_matcher \ + --volume $(APPLICATION_DIR)/service_sync:/tmp/.packages/service_sync \ --volume $(APPLICATION_DIR)/slack_messenger:/tmp/.packages/slack_messenger \ + --volume $(APPLICATION_DIR)/quality_checker:/tmp/.packages/quality_checker \ " -smoke-test: #Integration Smoke test for DI project - mandatory: PROFILE, ENVIRONMENT=test - make -s docker-run-tools \ - IMAGE=$$(make _docker-get-reg)/tester:latest \ - CMD="pytest steps -k smoke -vv --gherkin-terminal-reporter -p no:sugar -n auto --cucumberjson=./testresults.json --disable-pytest-warnings" \ +integration-test: # End to end test DI project - mandatory: PROFILE, TAG=[complete|dev]; optional: ENVIRONMENT, PARALLEL_TEST_COUNT + RUN_ID=$$RANDOM + echo RUN_ID=$$RUN_ID + make -s docker-run-tester \ + CMD="pytest steps -k $(TAG) -vvvv --gherkin-terminal-reporter -p no:sugar -n $(PARALLEL_TEST_COUNT) --cucumberjson=./testresults.json --reruns 2 --reruns-delay 10" \ DIR=./test/integration \ ARGS=" \ - -e API_KEY_SECRET=$(TF_VAR_api_gateway_api_key_name) \ - -e NHS_UK_API_KEY=$(TF_VAR_nhs_uk_api_key_key) \ - -e DOS_DB_PASSWORD_SECRET_NAME=$(DB_SECRET_NAME) \ - -e DOS_DB_PASSWORD_KEY=$(DB_SECRET_KEY) \ - -e DOS_DB_USERNAME_SECRET_NAME=$(DB_USER_NAME_SECRET_NAME) \ - -e DOS_DB_USERNAME_KEY=$(DB_USER_NAME_SECRET_KEY) \ - -e URL=https://$(DOS_INTEGRATION_URL) \ - -e EVENT_PROCESSOR=$(TF_VAR_event_processor_lambda_name) \ - -e EVENT_SENDER=$(TF_VAR_event_sender_lambda_name) \ - -e TEST_DB_CHECKER_FUNCTION_NAME=$(TF_VAR_test_db_checker_lambda_name) \ - -e EVENT_REPLAY=$(TF_VAR_event_replay_lambda_name) \ - -e DYNAMO_DB_TABLE=$(TF_VAR_change_events_table_name) \ - -e DOS_DB_IDENTIFIER_NAME=$(DB_SERVER_NAME) \ - -e RUN_ID=${RUN_ID} \ - -e CR_FIFO_DLQ=$(TF_VAR_cr_fifo_dlq_handler_lambda_name) \ + --env-file <(make _docker-get-variables-from-file VARS_FILE=$(VAR_DIR)/project.mk) \ + -e RUN_ID=$$RUN_ID \ " -integration-test-local: - cd test/integration - API_KEY_SECRET=$(TF_VAR_api_gateway_api_key_name) \ - NHS_UK_API_KEY=$(TF_VAR_nhs_uk_api_key_key) \ - DOS_DB_PASSWORD_SECRET_NAME=$(DB_SECRET_NAME) \ - DOS_DB_PASSWORD_KEY=$(DB_SECRET_KEY) \ - DOS_DB_USERNAME_SECRET_NAME=$(DB_USER_NAME_SECRET_NAME) \ - DOS_DB_USERNAME_KEY=$(DB_USER_NAME_SECRET_KEY) \ - URL=https://$(DOS_INTEGRATION_URL) \ - EVENT_PROCESSOR=$(TF_VAR_event_processor_lambda_name) \ - EVENT_SENDER=$(TF_VAR_event_sender_lambda_name) \ - TEST_DB_CHECKER_FUNCTION_NAME=$(TF_VAR_test_db_checker_lambda_name) \ - EVENT_REPLAY=$(TF_VAR_event_replay_lambda_name) \ - DYNAMO_DB_TABLE=$(TF_VAR_change_events_table_name) \ - DOS_DB_IDENTIFIER_NAME=$(DB_SERVER_NAME) \ - KEYALIAS=${TF_VAR_signing_key_alias} \ - RUN_ID=${RUN_ID} \ - pytest steps -k $(TAGS) -vv --gherkin-terminal-reporter -p no:sugar -n 8 --cucumberjson=./testresults.json - -integration-test: #End to end test DI project - mandatory: PROFILE, TAGS=[complete|dev]; optional: ENVIRONMENT, PARALLEL_TEST_COUNT - make -s docker-run-tools \ - IMAGE=$$(make _docker-get-reg)/tester:latest \ - CMD="pytest steps -k $(TAGS) -vv --gherkin-terminal-reporter -p no:sugar -n $(PARALLEL_TEST_COUNT) --cucumberjson=./testresults.json" \ - DIR=./test/integration \ - ARGS=" \ - -e API_KEY_SECRET=$(TF_VAR_api_gateway_api_key_name) \ - -e NHS_UK_API_KEY=$(TF_VAR_nhs_uk_api_key_key) \ - -e DOS_DB_PASSWORD_SECRET_NAME=$(DB_SECRET_NAME) \ - -e DOS_DB_PASSWORD_KEY=$(DB_SECRET_KEY) \ - -e DOS_DB_USERNAME_SECRET_NAME=$(DB_USER_NAME_SECRET_NAME) \ - -e DOS_DB_USERNAME_KEY=$(DB_USER_NAME_SECRET_KEY) \ - -e URL=https://$(DOS_INTEGRATION_URL) \ - -e EVENT_PROCESSOR=$(TF_VAR_event_processor_lambda_name) \ - -e EVENT_SENDER=$(TF_VAR_event_sender_lambda_name) \ - -e TEST_DB_CHECKER_FUNCTION_NAME=$(TF_VAR_test_db_checker_lambda_name) \ - -e EVENT_REPLAY=$(TF_VAR_event_replay_lambda_name) \ - -e DYNAMO_DB_TABLE=$(TF_VAR_change_events_table_name) \ - -e DOS_DB_IDENTIFIER_NAME=$(DB_SERVER_NAME) \ - -e RUN_ID=${RUN_ID} \ - -e CR_FIFO_DLQ=$(TF_VAR_cr_fifo_dlq_handler_lambda_name) \ - " +production-smoke-test: # Smoke test DI project - mandatory: PROFILE; optional: ENVIRONMENT + if [ "$(PROFILE)" != "live" ]; then + make -s docker-run-tester \ + CMD="pytest -vvvv --gherkin-terminal-reporter -p no:sugar --cucumberjson=./results/testresults.json" \ + DIR=./test/smoke \ + ARGS="--env-file <(make _docker-get-variables-from-file VARS_FILE=$(VAR_DIR)/project.mk)" + else + echo "Production smoke test not allowed on live profile" + exit 1 + fi + clean: # Runs whole project clean make \ docker-clean \ terraform-clean \ - serverless-clean \ python-clean \ - event-sender-clean \ - event-processor-clean \ - fifo-dlq-handler-clean \ - slack-messenger-clean \ - orchestrator-clean \ - cr-fifo-dlq-handler-clean \ - event-replay-clean \ - test-db-checker-handler-clean \ tester-clean \ - authoriser-clean \ - dos-api-gateway-clean \ performance-test-clean + rm -rf test/integration/replay/.*.txt + +remove-development-environments: # Removes development environments - mandatory: PROFILE + STACK_NAMES=$$(aws cloudformation list-stacks --output json | jq -r '.StackSummaries[] | select ( .StackName | contains("dos-integration")) | select (.StackStatus == "UPDATE_COMPLETE").StackName') + for ENVIRONMENT in $$STACK_NAMES; do \ + ENVIRONMENT=$$(echo $$ENVIRONMENT | sed -e "s/^uec-dos-integration-//"); + if [[ ! "$$ENVIRONMENT" =~ ^(dev|test|perf|perf2|release)$$ ]] && [[ "$$ENVIRONMENT" != *"-dev" ]]; then + make terraform-clean + echo "Removing $$ENVIRONMENT" + make undeploy PROFILE=dev ENVIRONMENT=$$ENVIRONMENT OPTS="--refresh=false" SHARED_ENVIRONMENT=$$ENVIRONMENT BLUE_GREEN_ENVIRONMENT=$$ENVIRONMENT + echo "Removed $$ENVIRONMENT" + fi + done # ============================================================================== -# Event Sender +# Change Event Dead Letter Queue Handler (change-event-dlq-handler) -event-sender-build: ### Build event sender lambda docker image - cp -f $(APPLICATION_DIR)/event_sender/requirements.txt $(DOCKER_DIR)/event-sender/assets/requirements.txt - cd $(APPLICATION_DIR)/event_sender - tar -czf $(DOCKER_DIR)/event-sender/assets/event-sender-app.tar.gz \ - --exclude=tests *.py ../common/*.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=event-sender - -event-sender-clean: ### Clean event sender lambda docker image directory - rm -f $(DOCKER_DIR)/event-sender/assets/*.tar.gz $(DOCKER_DIR)/event-sender/assets/*.txt - -event-sender-build-and-deploy: ### Build and deploy event sender lambda docker image - mandatory: PROFILE, ENVIRONMENT, FUNCTION_NAME - make build-and-deploy-single-function FUNCTION_NAME=event-sender +change-event-dlq-handler-build-and-deploy: ### Build and deploy change event dlq handler lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=change-event-dlq-handler CHANGE_EVENT_DLQ_HANDLER_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== -# Slack Messenger +# DoS DB Update Dead Letter Queue Handler (dos-db-update-dlq-handler) -slack-messenger-build: ### Build slack messenger lambda docker image - cp -f $(APPLICATION_DIR)/slack_messenger/requirements.txt $(DOCKER_DIR)/slack-messenger/assets/requirements.txt - cd $(APPLICATION_DIR)/slack_messenger - tar -czf $(DOCKER_DIR)/slack-messenger/assets/slack-messenger-app.tar.gz \ - --exclude=tests *.py ../common/*.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=slack-messenger - -slack-messenger-clean: ### Clean slack messenger lambda docker image directory - rm -f $(DOCKER_DIR)/slack-messenger/assets/*.tar.gz $(DOCKER_DIR)/slack-messenger/assets/*.txt - -slack-messenger-build-and-deploy: ### Build and deploy slack messenger lambda docker image - mandatory: PROFILE, ENVIRONMENT, FUNCTION_NAME - make build-and-deploy-single-function FUNCTION_NAME=slack-messenger +dos-db-update-dlq-handler-build-and-deploy: ### Build and deploy dos db update dlq handler lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=dos-db-update-dlq-handler DOS_DB_UPDATE_DLQ_HANDLER_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== -# Event Processor - -event-processor-build: ### Build event processor lambda docker image - cp -f $(APPLICATION_DIR)/event_processor/requirements.txt $(DOCKER_DIR)/event-processor/assets/requirements.txt - cd $(APPLICATION_DIR)/event_processor - tar -czf $(DOCKER_DIR)/event-processor/assets/event-processor-app.tar.gz \ - --exclude=tests *.py ../common/*.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=event-processor - -event-processor-clean: ### Clean event processor lambda docker image directory - rm -f $(DOCKER_DIR)/event-processor/assets/*.tar.gz $(DOCKER_DIR)/event-processor/assets/*.txt +# DoS DB Checker Handler (dos-db-handler) -event-processor-build-and-deploy: ### Build and deploy event processor lambda docker image - mandatory: PROFILE, ENVIRONMENT, FUNCTION_NAME - make build-and-deploy-single-function FUNCTION_NAME=event-processor +dos-db-handler-build-and-deploy: ### Build and deploy test db checker handler lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=dos-db-handler DOS_DB_HANDLER_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== -# First In First Out Dead Letter Queue Handler (fifo-dlq-handler) - -fifo-dlq-handler-build: ### Build fifo dlq handler lambda docker image - cp -f $(APPLICATION_DIR)/fifo_dlq_handler/requirements.txt $(DOCKER_DIR)/fifo-dlq-handler/assets/requirements.txt - cd $(APPLICATION_DIR)/fifo_dlq_handler - tar -czf $(DOCKER_DIR)/fifo-dlq-handler/assets/fifo-dlq-handler-app.tar.gz \ - --exclude=tests *.py ../common/*.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=fifo-dlq-handler - -fifo-dlq-handler-clean: ### Clean fifo dlq handler lambda docker image directory - rm -f $(DOCKER_DIR)/fifo-dlq-handler/assets/*.tar.gz $(DOCKER_DIR)/fifo-dlq-handler/assets/*.txt +# Event Replay lambda (event-replay) -fifo-dlq-handler-build-and-deploy: ### Build and deploy fifo dlq handler lambda docker image - mandatory: PROFILE, ENVIRONMENT, FUNCTION_NAME - make build-and-deploy-single-function FUNCTION_NAME=fifo-dlq-handler +event-replay-build-and-deploy: ### Build and deploy event replay lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=event-replay EVENT_REPLAY_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== -# CR Fifo Dead Letter Queue Handler (cr-fifo-dlq-handler) - -cr-fifo-dlq-handler-build: ### Build cr fifo dlq handler lambda docker image - cp -f $(APPLICATION_DIR)/cr_fifo_dlq_handler/requirements.txt $(DOCKER_DIR)/cr-fifo-dlq-handler/assets/requirements.txt - cd $(APPLICATION_DIR)/cr_fifo_dlq_handler - tar -czf $(DOCKER_DIR)/cr-fifo-dlq-handler/assets/cr-fifo-dlq-handler-app.tar.gz \ - --exclude=tests *.py ../common/*.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=cr-fifo-dlq-handler +# Ingest Change Event -cr-fifo-dlq-handler-clean: ### Clean cr fifo dlq handler lambda docker image directory - rm -f $(DOCKER_DIR)/cr-fifo-dlq-handler/assets/*.tar.gz $(DOCKER_DIR)/cr-fifo-dlq-handler/assets/*.txt - -cr-fifo-dlq-handler-build-and-deploy: ### Build and deploy cr fifo dlq handler lambda docker image - mandatory: PROFILE, ENVIRONMENT, FUNCTION_NAME - make build-and-deploy-single-function FUNCTION_NAME=cr-fifo-dlq-handler +ingest-change-event-build-and-deploy: ### Build and deploy ingest change event lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=ingest-change-event INGEST_CHANGE_EVENT_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== -# Event Replay lambda (event-replay) - -event-replay-build: ### Build event replay lambda docker image - cp -f $(APPLICATION_DIR)/event_replay/requirements.txt $(DOCKER_DIR)/event-replay/assets/requirements.txt - cd $(APPLICATION_DIR)/event_replay - tar -czf $(DOCKER_DIR)/event-replay/assets/event-replay-app.tar.gz \ - --exclude=tests *.py ../common/*.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=event-replay +# Send Email -event-replay-clean: ### Clean event replay lambda docker image directory - rm -f $(DOCKER_DIR)/event-replay/assets/*.tar.gz $(DOCKER_DIR)/event-replay/assets/*.txt - -event-replay-build-and-deploy: ### Build and deploy event replay lambda docker image - mandatory: PROFILE, ENVIRONMENT, FUNCTION_NAME - make build-and-deploy-single-function FUNCTION_NAME=event-replay +send-email-build-and-deploy: ### Build and deploy send email lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=send-email SEND_EMAIL_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== -# Test DB Checker Handler (test-db-checker-handler) - -test-db-checker-handler-build: ### Build test db checker handler lambda docker image - cp -f $(APPLICATION_DIR)/test_db_checker_handler/requirements.txt $(DOCKER_DIR)/test-db-checker-handler/assets/requirements.txt - cd $(APPLICATION_DIR)/test_db_checker_handler - tar -czf $(DOCKER_DIR)/test-db-checker-handler/assets/test-db-checker-handler-app.tar.gz \ - --exclude=tests *.py ../common/*.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=test-db-checker-handler - -test-db-checker-handler-clean: ### Clean test db checker handler lambda docker image directory - rm -f $(DOCKER_DIR)/test-db-checker-handler/assets/*.tar.gz $(DOCKER_DIR)/test-db-checker-handler/assets/*.txt +# Service Matcher -test-db-checker-handler-build-and-deploy: ### Build and deploy test db checker handler lambda docker image - mandatory: PROFILE, ENVIRONMENT, FUNCTION_NAME - make build-and-deploy-single-function FUNCTION_NAME=test-db-checker-handler +service-matcher-build-and-deploy: ### Build and deploy service matcher lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=service-matcher SERVICE_MATCHER_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== -# Orchestrator +# Service Sync -orchestrator-build: ### Build orchestrator lambda docker image - cp -f $(APPLICATION_DIR)/orchestrator/requirements.txt $(DOCKER_DIR)/orchestrator/assets/requirements.txt - cd $(APPLICATION_DIR)/orchestrator - tar -czf $(DOCKER_DIR)/orchestrator/assets/orchestrator-app.tar.gz \ - --exclude=tests *.py ../common/*.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=orchestrator - -orchestrator-clean: ### Clean event processor lambda docker image directory - rm -f $(DOCKER_DIR)/orchestrator/assets/*.tar.gz $(DOCKER_DIR)/orchestrator/assets/*.txt - -orchestrator-build-and-deploy: ### Build and deploy orchestrator lambda docker image - mandatory: PROFILE, ENVIRONMENT, FUNCTION_NAME - make build-and-deploy-single-function FUNCTION_NAME=orchestrator +service-sync-build-and-deploy: ### Build and deploy service sync lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=service-sync SERVICE_SYNC_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== -# Authoriser (for dos api gateway mock) - -authoriser-build: ### Build authoriser lambda docker image - cp -f $(APPLICATION_DIR)/authoriser/requirements.txt $(DOCKER_DIR)/authoriser/assets/requirements.txt - cd $(APPLICATION_DIR)/authoriser - tar -czf $(DOCKER_DIR)/authoriser/assets/authoriser-app.tar.gz \ - --exclude=tests *.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=authoriser +# Slack Messenger -authoriser-clean: ### Clean authoriser lambda docker image directory - rm -f $(DOCKER_DIR)/authoriser/assets/*.tar.gz $(DOCKER_DIR)/authoriser/assets/*.txt +slack-messenger-build-and-deploy: ### Build and deploy slack messenger lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=slack-messenger SLACK_MESSENGER_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== -# DoS API Gateway Mock lambda - -dos-api-gateway-build: - cp -f $(APPLICATION_DIR)/dos_api_gateway/requirements.txt $(DOCKER_DIR)/dos-api-gateway/assets/requirements.txt - cd $(APPLICATION_DIR)/dos_api_gateway - tar -czf $(DOCKER_DIR)/dos-api-gateway/assets/dos-api-gateway-app.tar.gz \ - --exclude=tests *.py > /dev/null 2>&1 - cd $(PROJECT_DIR) - make docker-image NAME=dos-api-gateway +# Quality Checker -dos-api-gateway-clean: ### Clean event processor lambda docker image directory - rm -f $(DOCKER_DIR)/dos-api-gateway/assets/*.tar.gz $(DOCKER_DIR)/dos-api-gateway/assets/*.txt - -mock-dos-api-gateway-deployment: - make terraform-apply-auto-approve STACKS=dos-api-gateway-mock +quality-checker-build-and-deploy: ### Build and deploy quality checker lambda docker image - mandatory: PROFILE, ENVIRONMENT + make build-and-deploy-single-function FUNCTION_NAME=quality-checker QUALITY_CHECKER_VERSION=$(BUILD_TAG) VERSION=$(BUILD_TAG) # ============================================================================== # Deployments -sls-only-deploy: # Deploys all lambdas - mandatory: PROFILE, VERSION=[commit hash-timestamp/latest] - eval "$$(make -s populate-deployment-variables)" - make serverless-deploy - quick-build-and-deploy: # Build and deploy lambdas only (meant to for fast redeployment of existing lambdas) - mandatory: PROFILE, ENVIRONMENT make -s build VERSION=$(BUILD_TAG) make -s push-images VERSION=$(BUILD_TAG) - make -s sls-only-deploy VERSION=$(BUILD_TAG) + eval "$$(make -s populate-tagging-variables)" + make terraform-apply-auto-approve STACKS=application VERSION=$(BUILD_TAG) build-and-deploy-single-function: # Build and deploy single lambda only (meant to for fast redeployment of existing lambda) - mandatory: PROFILE, ENVIRONMENT - make $(FUNCTION_NAME)-build VERSION=$(BUILD_TAG) - make docker-push NAME=$(FUNCTION_NAME) VERSION=$(BUILD_TAG) - eval "$$(make -s populate-deployment-variables)" - make serverless-deploy-single-function FUNCTION_NAME=$(FUNCTION_NAME) VERSION=$(BUILD_TAG) + make build-lambda GENERIC_IMAGE_NAME=lambda NAME=$(FUNCTION_NAME) + make docker-push NAME=$(FUNCTION_NAME) + eval "$$(make -s get-lambda-versions-if-empty)" + eval "$$(make -s populate-tagging-variables)" + make terraform-apply-auto-approve STACKS=application + +deploy-application-with-same-image-versions: # Deploy application with same image versions - mandatory: PROFILE, ENVIRONMENT + eval "$$(make -s populate-tagging-variables)" + eval "$$(make -s get-lambda-versions-from-terraform-stack)" + make terraform-apply-auto-approve STACKS=application + +get-lambda-versions-if-empty: + VERSIONS=$$(make -s terraform-output STACKS=application OPTS='-json lambda_versions' | tail -n1) + [[ -z "$$CHANGE_EVENT_DLQ_HANDLER_VERSION" ]] && echo "export CHANGE_EVENT_DLQ_HANDLER_VERSION=$$(echo $$VERSIONS | jq -r '.change_event_dlq_handler')" + [[ -z "$$DOS_DB_HANDLER_VERSION" ]] && echo "export DOS_DB_HANDLER_VERSION=$$(echo $$VERSIONS | jq -r '.dos_db_handler')" + [[ -z "$$DOS_DB_UPDATE_DLQ_HANDLER_VERSION" ]] && echo "export DOS_DB_UPDATE_DLQ_HANDLER_VERSION=$$(echo $$VERSIONS | jq -r '.dos_db_update_dlq_handler')" + [[ -z "$$EVENT_REPLAY_VERSION" ]] && echo "export EVENT_REPLAY_VERSION=$$(echo $$VERSIONS | jq -r '.event_replay')" + [[ -z "$$INGEST_CHANGE_EVENT_VERSION" ]] && echo "export INGEST_CHANGE_EVENT_VERSION=$$(echo $$VERSIONS | jq -r '.ingest_change_event')" + [[ -z "$$SEND_EMAIL_VERSION" ]] && echo "export SEND_EMAIL_VERSION=$$(echo $$VERSIONS | jq -r '.send_email')" + [[ -z "$$SERVICE_MATCHER_VERSION" ]] && echo "export SERVICE_MATCHER_VERSION=$$(echo $$VERSIONS | jq -r '.service_matcher')" + [[ -z "$$SERVICE_SYNC_VERSION" ]] && echo "export SERVICE_SYNC_VERSION=$$(echo $$VERSIONS | jq -r '.service_sync')" + [[ -z "$$SLACK_MESSENGER_VERSION" ]] && echo "export SLACK_MESSENGER_VERSION=$$(echo $$VERSIONS | jq -r '.slack_messenger')" + [[ -z "$$QUALITY_CHECKER_VERSION" ]] && echo "export QUALITY_CHECKER_VERSION=$$(echo $$VERSIONS | jq -r '.quality_checker')" push-images: # Use VERSION=[] to push a perticular version otherwise with default to latest - make docker-push NAME=event-sender - make docker-push NAME=event-processor - make docker-push NAME=fifo-dlq-handler - make docker-push NAME=cr-fifo-dlq-handler - make docker-push NAME=event-replay - make docker-push NAME=test-db-checker-handler - make docker-push NAME=orchestrator - make docker-push NAME=authoriser - make docker-push NAME=dos-api-gateway - make docker-push NAME=slack-messenger + for IMAGE_NAME in $$(echo $(PROJECT_LAMBDAS_LIST) | tr "," "\n"); do + make docker-push NAME=$$IMAGE_NAME + done push-tester-image: make docker-push NAME=tester # ============================================================================== -# Pipelines +# Development Tools -deploy-development-pipeline: - make terraform-apply-auto-approve STACKS=development-pipeline PROFILE=tools +deploy-development-and-deployment-tools: + TF_VAR_github_token=$$(make -s secret-get-existing-value NAME=uec-dos-int-tools/deployment KEY=GITHUB_TOKEN) + make terraform-apply-auto-approve STACKS=development-and-deployment-tools PROFILE=tools ENVIRONMENT=dev TF_VAR_github_token=$$TF_VAR_github_token -undeploy-development-pipeline: - make terraform-destroy-auto-approve STACKS=development-pipeline PROFILE=tools +undeploy-development-and-deployment-tools: + make terraform-destroy-auto-approve STACKS=development-and-deployment-tools PROFILE=tools ENVIRONMENT=dev TF_VAR_github_token="any" -plan-development-pipeline: - if [ "$(PROFILE)" == "tools" ]; then - export TF_VAR_github_token=$$(make -s secret-get-existing-value NAME=$(DEPLOYMENT_SECRETS) KEY=GITHUB_TOKEN) - make terraform-plan STACKS=development-pipeline - else - echo "Only tools profile supported at present" - fi - -deploy-deployment-pipelines: - if [ "$(PROFILE)" == "tools" ]; then - TF_VAR_github_token=$$(make -s secret-get-existing-value NAME=$(DEPLOYMENT_SECRETS) KEY=GITHUB_TOKEN) - make terraform-apply-auto-approve STACKS=deployment-pipelines TF_VAR_github_token=$$TF_VAR_github_token - else - echo "PROFILE must be tools and ENVIRONMENT must be dev" - fi - -undeploy-deployment-pipelines: - if [ "$(PROFILE)" == "tools" ]; then - TF_VAR_github_token=$$(make -s secret-get-existing-value NAME=$(DEPLOYMENT_SECRETS) KEY=GITHUB_TOKEN) - make terraform-destroy-auto-approve STACKS=deployment-pipelines TF_VAR_github_token=$$TF_VAR_github_token - else - echo "PROFILE must be tools and ENVIRONMENT must be dev" - fi - -plan-deployment-pipelines: - if [ "$(PROFILE)" == "tools" ] && [ "$(ENVIRONMENT)" == "dev" ]; then - TF_VAR_github_token=$$(make -s secret-get-existing-value NAME=$(DEPLOYMENT_SECRETS) KEY=GITHUB_TOKEN) - make terraform-plan STACKS=deployment-pipelines TF_VAR_github_token=$$TF_VAR_github_token - else - echo "PROFILE must be tools and ENVIRONMENT must be dev" - fi - -deploy-performance-pipelines: - make terraform-apply-auto-approve STACKS=performance-pipelines PROFILE=dev - -undeploy-performance-pipelines: - make terraform-destroy-auto-approve STACKS=performance-pipelines PROFILE=dev - -plan-performance-pipelines: - if [ "$(PROFILE)" == "dev" ]; then - export TF_VAR_github_token=$$(make -s secret-get-existing-value NAME=$(DEPLOYMENT_SECRETS) KEY=GITHUB_TOKEN) - make terraform-plan STACKS=performance-pipelines - else - echo "Only dev profile supported at present" - fi +plan-development-and-deployment-tools: + TF_VAR_github_token=$$(make -s secret-get-existing-value NAME=uec-dos-int-tools/deployment KEY=GITHUB_TOKEN) + make terraform-plan STACKS=development-and-deployment-tools PROFILE=tools ENVIRONMENT=dev TF_VAR_github_token=$$TF_VAR_github_token docker-hub-signin: # Sign into Docker hub export DOCKER_USERNAME=$$($(AWSCLI) secretsmanager get-secret-value --secret-id uec-pu-updater/deployment --version-stage AWSCURRENT --region $(AWS_REGION) --query '{SecretString: SecretString}' | jq --raw-output '.SecretString' | jq -r .DOCKER_HUB_USERNAME) @@ -458,19 +260,19 @@ tag-commit-for-deployment: # Tag git commit for deployment - mandatory: PROFILE= make git-tag-create-environment-deployment COMMIT=$(COMMIT) else echo PROFILE=$(PROFILE) should equal ENVIRONMENT=$(ENVIRONMENT) - echo Recommended: you run this command from the master branch + echo Recommended: you run this command from the main branch fi -tag-commit-to-destroy-environment: # Tag git commit to destroy deployment - mandatory: ENVIRONMENT=[di-number], COMMIT=[short commit hash] +tag-commit-to-destroy-environment: # Tag git commit to destroy deployment - mandatory: ENVIRONMENT=[ds-number], COMMIT=[short commit hash] if [ "$(PROFILE)" != "$(ENVIRONMENT)" ]; then tag=$(ENVIRONMENT)-destroy-$(BUILD_TIMESTAMP) make git-tag-create TAG=$$tag COMMIT=$(COMMIT) else - echo This is for destroying old task environments PROFILE should not be equal to ENVIRONMENT + echo This is for destroying old dev environments PROFILE should not be equal to ENVIRONMENT fi get-environment-from-pr: - ENVIRONMENT=$$(gh pr list -s merged --json number,mergeCommit,headRefName --repo=nhsd-exeter/dos-integration | jq --raw-output '.[] | select(.number == $(PR_NUMBER)) | .headRefName | sub( ".*:*/DI-(?.[0-9]*).*"; "di-\(.x)") ') + ENVIRONMENT=$$(gh pr list -s merged --json number,mergeCommit,headRefName --repo=nhsd-exeter/dos-integration | jq --raw-output '.[] | select(.number == $(PR_NUMBER)) | .headRefName | sub( ".*:*/DS-(?.[0-9]*).*"; "ds-\(.x)") ') echo $$ENVIRONMENT is-environment-deployed: @@ -479,13 +281,19 @@ is-environment-deployed: slack-codebuild-notification: ### Send codebuild pipeline notification - mandatory: PIPELINE_NAME,BUILD_STATUS=[success|failure] time=$$(( $(shell date +"%s") - $(shell date -d '$(BUILD_DATE)' +"%s") )) + if [[ ! -z "$(PR)" ]]; then + NAME=codebuild-pipeline-pr-merge-$(shell echo $(BUILD_STATUS) | tr '[:upper:]' '[:lower:]') + else + NAME=codebuild-pipeline-$(shell echo $(BUILD_STATUS) | tr '[:upper:]' '[:lower:]') + fi + echo NAME=$$NAME make slack-send-notification \ - NAME=codebuild-pipeline-$(shell echo $(BUILD_STATUS) | tr '[:upper:]' '[:lower:]') \ - BUILD_TIME=$$(( $$time / 60 ))m$$(( $$time % 60 ))s \ - BUILD_URL=$$(echo https://$(AWS_REGION).console.aws.amazon.com/codesuite/codebuild/$(AWS_ACCOUNT_ID_MGMT)/projects/$(CODEBUILD_PROJECT_NAME)/build/$(CODEBUILD_BUILD_ID)/log?region=$(AWS_REGION)) \ - SLACK_WEBHOOK_URL=$$(make -s secret-get-existing-value NAME=$(SLACK_WEBHOOK_SECRET_NAME) KEY=$(SLACK_WEBHOOK_SECRET_KEY)) + NAME=$$NAME \ + BUILD_TIME=$$(( $$time / 60 ))m$$(( $$time % 60 ))s \ + BUILD_URL=$$(echo https://$(AWS_REGION).console.aws.amazon.com/codesuite/codebuild/$(AWS_ACCOUNT_ID_MGMT)/projects/$(CODEBUILD_PROJECT_NAME)/build/$(CODEBUILD_BUILD_ID)/log?region=$(AWS_REGION)) \ + SLACK_WEBHOOK_URL=$$(make -s secret-get-existing-value NAME=$(DEPLOYMENT_SECRETS) KEY=SLACK_WEBHOOK) -aws-ecr-cleanup: # Mandatory: REPOS=[comma separated list of ECR repo names e.g. event-sender,slack-messenger] +aws-ecr-cleanup: # Mandatory: REPOS=[comma separated list of ECR repo names e.g. service-sync,slack-messenger] export THIS_YEAR=$$(date +%Y) export LAST_YEAR=$$(date -d "1 year ago" +%Y) DELETE_IMAGES_OLDER_THAN=$$(date +%s --date='1 month ago') @@ -493,10 +301,10 @@ aws-ecr-cleanup: # Mandatory: REPOS=[comma separated list of ECR repo names e.g. REPOSITORY_NAME=$$(echo $(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT)/$$REPOSITORY) echo Repository is $$REPOSITORY_NAME make remove-untagged-images REPOSITORY=$$REPOSITORY_NAME - make remove-task-images REPOSITORY=$$REPOSITORY_NAME DELETE_IMAGES_OLDER_THAN=$$DELETE_IMAGES_OLDER_THAN + make remove-dev-images REPOSITORY=$$REPOSITORY_NAME DELETE_IMAGES_OLDER_THAN=$$DELETE_IMAGES_OLDER_THAN done -remove-task-images: # Removes task ecr images in repository older than certain date, REPOSITORY=[$(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT)/REPOSITORY_NAME], DELETE_IMAGES_OLDER_THAN=[date/time in epoch] +remove-dev-images: # Removes dev ecr images in repository older than certain date, REPOSITORY=[$(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT)/REPOSITORY_NAME], DELETE_IMAGES_OLDER_THAN=[date/time in epoch] COUNTER=0 IMAGE_IDS=$$(aws ecr describe-images --registry-id $(AWS_ACCOUNT_ID_MGMT) --region $(AWS_REGION) --repository-name $(REPOSITORY) --filter "tagStatus=TAGGED" --max-items 1000 --output json | jq -r '.imageDetails[] | select (.imageTags[0] | contains("$(LAST_YEAR)") or contains ("$(THIS_YEAR)")) | select (.imagePushedAt < $(DELETE_IMAGES_OLDER_THAN)).imageDigest') for DIGEST in $$(echo $$IMAGE_IDS | tr " " "\n"); do @@ -529,19 +337,8 @@ batch-delete-ecr-images: # Mandatory - LIST_OF_DIGESTS: [list of "sha:digest" se # Tester tester-build: ### Build tester docker image - cp -f $(APPLICATION_DIR)/requirements-dev.txt $(DOCKER_DIR)/tester/assets/ - cp -f $(APPLICATION_DIR)/event_processor/requirements.txt $(DOCKER_DIR)/tester/assets/requirements-processor.txt - cp -f $(APPLICATION_DIR)/event_sender/requirements.txt $(DOCKER_DIR)/tester/assets/requirements-sender.txt - cp -f $(APPLICATION_DIR)/slack_messenger/requirements.txt $(DOCKER_DIR)/tester/assets/requirements-messenger.txt - cp -f $(APPLICATION_DIR)/orchestrator/requirements.txt $(DOCKER_DIR)/tester/assets/requirements-orchestrator.txt - cp -f $(APPLICATION_DIR)/fifo_dlq_handler/requirements.txt $(DOCKER_DIR)/tester/assets/requirements-fifo-dlq-hander.txt - cp -f $(APPLICATION_DIR)/cr_fifo_dlq_handler/requirements.txt $(DOCKER_DIR)/tester/assets/requirements-cr-fifo-dlq-hander.txt - cp -f $(APPLICATION_DIR)/event_replay/requirements.txt $(DOCKER_DIR)/tester/assets/requirements-event-replay.txt - cp -f $(APPLICATION_DIR)/test_db_checker_handler/requirements.txt $(DOCKER_DIR)/tester/assets/requirements-test-db-checker-handler.txt - cat build/docker/tester/assets/requirements*.txt | sort --unique >> $(DOCKER_DIR)/tester/assets/requirements.txt - rm -f $(DOCKER_DIR)/tester/assets/requirements-*.txt - make docker-image NAME=tester - make tester-clean + cat $(APPLICATION_DIR)/*/requirements.txt $(APPLICATION_DIR)/requirements-dev.txt | sort --unique > $(DOCKER_DIR)/tester/assets/requirements.txt + make -s docker-image NAME=tester tester-clean: rm -fv $(DOCKER_DIR)/tester/assets/*.txt @@ -552,135 +349,41 @@ tester-clean: # ----------------------------- # Performance Testing -stress-test: # Create change events for stress performance testing - mandatory: PROFILE, ENVIRONMENT, START_TIME=[timestamp], optional: PIPELINE=true/false - if [ $(PIPELINE) == true ]; then - PERFORMANCE_ARGS=$$(echo --users 5 --spawn-rate 5 --run-time 30s) - else - PERFORMANCE_ARGS=$$(echo --users 10 --spawn-rate 2 --run-time 10m) - fi - make -s docker-run-tools \ - IMAGE=$$(make _docker-get-reg)/tester \ - CMD="python -m locust -f stress_test_locustfile.py --headless \ - $$PERFORMANCE_ARGS --stop-timeout 10 --exit-code-on-error 0 \ - -H https://$(DOS_INTEGRATION_URL) \ - --csv=results/$(START_TIME)_create_change_events" \ - DIR=./test/performance/create_change_events \ - ARGS="\ - -p 8089:8089 \ - -e API_KEY_SECRET_NAME=$(TF_VAR_api_gateway_api_key_name) \ - -e API_KEY_SECRET_KEY=$(TF_VAR_nhs_uk_api_key_key) \ - -e CHANGE_EVENTS_TABLE_NAME=$(TF_VAR_change_events_table_name) \ - " +stress-test: # Create change events for stress performance testing - mandatory: PROFILE, ENVIRONMENT, START_TIME=[timestamp] + make -s docker-run-tester \ + CMD="python -m locust -f stress_test.py --headless \ + --users 12 --spawn-rate 10 --run-time 12m \ + --stop-timeout 10 --exit-code-on-error 0 -H $(HTTPS_DOS_INTEGRATION_URL) \ + " $(PERFORMANCE_TEST_DIR_AND_ARGS) load-test: # Create change events for load performance testing - mandatory: PROFILE, ENVIRONMENT, START_TIME=[timestamp] - make -s docker-run-tools \ - IMAGE=$$(make _docker-get-reg)/tester \ - CMD="python -m locust -f load_test_locustfile.py --headless \ - --users 50 --spawn-rate 2 --run-time 30m --stop-timeout 5 --exit-code-on-error 0 \ - -H https://$(DOS_INTEGRATION_URL) \ - --csv=results/$(START_TIME)_create_change_events" \ - DIR=./test/performance/create_change_events \ - ARGS="\ - -p 8089:8089 \ - -e API_KEY_SECRET_NAME=$(TF_VAR_api_gateway_api_key_name) \ - -e API_KEY_SECRET_KEY=$(TF_VAR_nhs_uk_api_key_key) \ - -e CHANGE_EVENTS_TABLE_NAME=$(TF_VAR_change_events_table_name) \ - " - -performance-test-data-collection: # Runs data collection for performance tests - mandatory: PROFILE, ENVIRONMENT, START_TIME=[timestamp], END_TIME=[timestamp] - make -s docker-run-tools \ - IMAGE=$$(make _docker-get-reg)/tester \ - CMD="python data_collection.py" \ - DIR=./test/performance/data_collection \ - ARGS="\ - -e START_TIME=$(START_TIME) \ - -e END_TIME=$(END_TIME) \ - -e FIFO_QUEUE_NAME=$(TF_VAR_fifo_queue_name) \ - -e FIFO_DLQ_NAME=$(TF_VAR_dead_letter_queue_from_fifo_queue_name) \ - -e EVENT_SENDER_NAME=$(TF_VAR_event_sender_lambda_name) \ - -e EVENT_PROCESSOR_NAME=$(TF_VAR_event_processor_lambda_name) \ - -e RDS_INSTANCE_IDENTIFIER=$(DB_SERVER_NAME) \ - " - -generate-performance-test-details: # Generates performance test details - mandatory: PROFILE, ENVIRONMENT, START_TIME=[timestamp], END_TIME=[timestamp], TEST_TYPE="string", CODE_VERSION="string" - rm -rf $(TMP_DIR)/performance - mkdir $(TMP_DIR)/performance - echo -e "PROFILE=$(PROFILE)\nENVIRONMENT=$(ENVIRONMENT)\nTEST_TYPE=$(TEST_TYPE)\nCODE_VERSION=$(CODE_VERSION)\nSTART_TIME=$(START_TIME)\nEND_TIME=$(END_TIME)" > $(TMP_DIR)/performance/test_details.txt - cp test/performance/create_change_events/results/$(START_TIME)* $(TMP_DIR)/performance - cp test/performance/data_collection/results/$(START_TIME)* $(TMP_DIR)/performance - zip -r $(TMP_DIR)/$(START_TIME)-$(ENVIRONMENT)-performance-tests.zip $(TMP_DIR)/performance - aws s3 cp $(TMP_DIR)/$(START_TIME)-$(ENVIRONMENT)-performance-tests.zip s3://uec-dos-int-performance-tests-nonprod/$(START_TIME)-$(ENVIRONMENT)-performance-tests.zip + make -s docker-run-tester \ + CMD="python -m locust -f load_test.py --headless \ + --users 50 --spawn-rate 5 --exit-code-on-error 0 \ + -H $(HTTPS_DOS_INTEGRATION_URL) \ + " $(PERFORMANCE_TEST_DIR_AND_ARGS) + +PERFORMANCE_TEST_DIR_AND_ARGS= \ + DIR=./test/performance \ + ARGS="-p 8089:8089 --env-file <(make _docker-get-variables-from-file VARS_FILE=$(VAR_DIR)/project.mk)" + +performance-test-results: # Get performance test results - mandatory: PROFILE, ENVIRONMENT, START_TIME=[timestamp], END_TIME=[timestamp] +# Timestamps are in ISO 1806 format e.g. 2023-01-01T00:00:00Z +# Example: make performance-test-results PROFILE=perf ENVIRONMENT=perf START_TIME=2023-11-28T10:00:00Z END_TIME=2023-11-28T12:00:00Z + make -s docker-run-tester CMD="python scripts/performance_test_results/performance_test_results.py" \ + ARGS="--env-file <(make _docker-get-variables-from-file VARS_FILE=$(VAR_DIR)/project.mk) \ + --env START_TIME=$(START_TIME) --env END_TIME=$(END_TIME)" performance-test-clean: # Clean up performance test results rm -rf $(TMP_DIR)/performance rm -f $(TMP_DIR)/*.zip - rm -rf $(PROJECT_DIR)/test/performance/create_change_events/results/*.csv - rm -rf $(PROJECT_DIR)/test/performance/data_collection/results/*.csv - -stress-test-in-pipeline: # An all in one stress test make target - START_TIME=$$(date +%Y-%m-%d_%H-%M-%S) - AWS_START_TIME=$$(date +%FT%TZ) - CODE_VERSION=$$($(AWSCLI) lambda get-function --function-name $(TF_VAR_event_processor_lambda_name) | jq --raw-output '.Configuration.Environment.Variables.CODE_VERSION') - make stress-test START_TIME=$$START_TIME PIPELINE=true - sleep 4.5h - END_TIME=$$(date +%Y-%m-%d_%H-%M-%S) - AWS_END_TIME=$$(date +%FT%TZ) - make performance-test-data-collection START_TIME=$$START_TIME END_TIME=$$END_TIME - make generate-performance-test-details START_TIME=$$START_TIME END_TIME=$$END_TIME TEST_TYPE="stress test" CODE_VERSION=$$CODE_VERSION - make send-performance-dashboard-slack-message START_DATE_TIME=$$AWS_START_TIME END_DATE_TIME=$$AWS_END_TIME - -load-test-in-pipeline: # An all in one load test make target - START_TIME=$$(date +%Y-%m-%d_%H-%M-%S) - AWS_START_TIME=$$(date +%FT%TZ) - CODE_VERSION=$$($(AWSCLI) lambda get-function --function-name $(TF_VAR_event_processor_lambda_name) | jq --raw-output '.Configuration.Environment.Variables.CODE_VERSION') - make load-test START_TIME=$$START_TIME - sleep 10m - END_TIME=$$(date +%Y-%m-%d_%H-%M-%S) - AWS_END_TIME=$$(date +%FT%TZ) - make performance-test-data-collection START_TIME=$$START_TIME END_TIME=$$END_TIME - make generate-performance-test-details START_TIME=$$START_TIME END_TIME=$$END_TIME TEST_TYPE="load test" CODE_VERSION=$$CODE_VERSION - make send-performance-dashboard-slack-message START_DATE_TIME=$$AWS_START_TIME END_DATE_TIME=$$AWS_END_TIME - -send-performance-dashboard-slack-message: - aws sns publish --topic-arn arn:aws:sns:$(AWS_REGION):$(AWS_ACCOUNT_ID_NONPROD):uec-dos-int-dev-pipeline-topic --message '{ - "version": "0", - "id": "13cde686-328b-6117-af20-0e5566167482", - "detail-type": "Performance Dashboard Here - https://$(AWS_REGION).console.aws.amazon.com/cloudwatch/home?region=$(AWS_REGION)#dashboards:name=$(TF_VAR_cloudwatch_monitoring_dashboard_name);start=$(START_DATE_TIME);end=$(END_DATE_TIME)", - "source": "aws.ecr", - "account": "$(AWS_ACCOUNT_ID_NONPROD)", - "time": "2019-11-16T01:54:34Z", - "region": "$(AWS_REGION)", - "resources": [], - "detail": {} - }' - -# ----------------------------- -# Chaos Testing - -setup-no-dos-chaos-test: # Setup chaos test environment (Sets DoS API Gateway mock to be unavailable) - mandatory: PROFILE; optional: ENVIRONMENT - make terraform-destroy-auto-approve STACKS="dos-api-gateway-mock" OPTS="-target aws_route53_record.uec_dos_integration_api_endpoint" - -restore-from-no-dos-chaos-test: # Restore from chaos test environment - mandatory: PROFILE; optional: ENVIRONMENT - VERSION=$$(echo $(BUILD_TAG)) - make build-and-push-mock-dos-api-gateway-docker-images VERSION=$$VERSION - make mock-dos-api-gateway-deployment VERSION=$$VERSION - -setup-circuit-breaker-chaos-test: # Setup chaos test environment (Sets DoS API Gateway mock to return 500 errors) - mandatory: PROFILE; optional: ENVIRONMENT - VERSION=$$(echo $(BUILD_TAG)) - make build-and-push-mock-dos-api-gateway-docker-images VERSION=$$VERSION - make mock-dos-api-gateway-deployment VERSION=$$VERSION TF_VAR_chaos_mode="true" - -restore-from-circuit-breaker-chaos-test: # Restore from chaos test environment - mandatory: PROFILE; optional: ENVIRONMENT - VERSION=$$(echo $(BUILD_TAG)) - make build-and-push-mock-dos-api-gateway-docker-images VERSION=$$VERSION - make mock-dos-api-gateway-deployment VERSION=$$VERSION + rm -rf $(PROJECT_DIR)/test/performance/results/*.csv # ----------------------------- # Other update-all-ip-allowlists: # Update your IP address in AWS secrets manager to acesss non-prod environments - mandatory: PROFILE, ENVIRONMENT, USERNAME USERNAME=$$(git config user.name) - make -s update-ip-allowlist PROFILE=task USERNAME="$$USERNAME" make -s update-ip-allowlist PROFILE=dev USERNAME="$$USERNAME" update-ip-allowlist: # Update your IP address in AWS secrets manager to acesss non-prod environments - mandatory: PROFILE, ENVIRONMENT, USERNAME @@ -700,21 +403,160 @@ delete-ip-from-allowlist: # Update your IP address in AWS secrets manager to ace CMD="python delete-ip-address.py $(USERNAME)" \ DIR=$(BIN_DIR) ARGS="-e IP_SECRET=$(TF_VAR_ip_address_secret)" -python-linting: - make python-code-check FILES=application - make python-code-check FILES=test - -python-format: - make python-code-format FILES=application - make python-code-format FILES=test +trigger-dos-deployment-pipeline: + JENKINS_URL=$$(make -s secret-get-existing-value NAME=uec-dos-int-dev/deployment KEY=JENKINS_MOM_URL) + JENKINS_USERNAME=$$(make -s secret-get-existing-value NAME=uec-dos-int-dev/deployment KEY=JENKINS_API_USERNAME) + JENKINS_PASSWORD=$$(make -s secret-get-existing-value NAME=uec-dos-int-dev/deployment KEY=JENKINS_API_PASSWORD) + JENKINS_CRUMB=$$(curl -L -X GET "$$JENKINS_URL/crumbIssuer/api/json" --user $$JENKINS_USERNAME:$$JENKINS_PASSWORD --cookie-jar jenkins.cookies | jq --raw-output '.crumb') + curl -L -X POST "$$JENKINS_URL/view/DoS%20Deploy/job/Deploy%20App%20and%20Cron/job/develop/buildWithParameters" --cookie jenkins.cookies \ + --user $$JENKINS_USERNAME:$$JENKINS_PASSWORD -H "Jenkins-Crumb: $$JENKINS_CRUMB" \ + --data TARGET="regressiondi" --data IMAGE_TAG="8.14.1_8894b744" --data REFRESH="true" + echo Jenkins Job has started + echo Sleeping for 5 minutes + sleep 300 + echo Jenkins Job expected to have finished + rm -rf jenkins.cookies create-ecr-repositories: - make docker-create-repository NAME=event-processor - make docker-create-repository NAME=event-sender - make docker-create-repository NAME=fifo-dlq-handler - make docker-create-repository NAME=cr-fifo-dlq-handler - make docker-create-repository NAME=orchestrator + make docker-create-repository NAME=change-event-dlq-handler + make docker-create-repository NAME=dos-db-handler + make docker-create-repository NAME=dos-db-update-dlq-handler make docker-create-repository NAME=event-replay + make docker-create-repository NAME=service-matcher + make docker-create-repository NAME=service-sync make docker-create-repository NAME=slack-messenger - make docker-create-repository NAME=test-db-checker-handler make docker-create-repository NAME=tester + +terraform-security: + make docker-run-terraform-tfsec DIR=infrastructure CMD="tfsec" + +# ============================================================================== +# Checkov (Code Security Best Practices) + +docker-best-practices: + make docker-run-checkov DIR=/build/docker CHECKOV_OPTS="--framework dockerfile --skip-check CKV_DOCKER_2,CKV_DOCKER_3,CKV_DOCKER_4" + +terraform-best-practices: + make docker-run-checkov DIR=/infrastructure CHECKOV_OPTS="--framework terraform --skip-check CKV_AWS_120,CKV_AWS_147,CKV_AWS_149,CKV_AWS_225,CKV_AWS_338,CKV_AWS_316,CKV_AWS_337,CKV_TF_1" + +github-actions-best-practices: + make docker-run-checkov DIR=/.github CHECKOV_OPTS="--skip-check CKV_GHA_2" + +checkov-secret-scanning: + make docker-run-checkov CHECKOV_OPTS="--framework secrets" + +# ============================================================================== +# Blue/Green Deployment Targets + +deploy-shared-resources: # Deploys shared resources (Only intended to run in pipeline) - mandatory: PROFILE, ENVIRONMENT, SHARED_ENVIRONMENT, BLUE_GREEN_ENVIRONMENT + eval "$$(make -s populate-tagging-variables)" + make terraform-apply-auto-approve STACKS=api-key,shared-resources + +deploy-blue-green-environment: # Deploys blue/green resources (Only intended to run in pipeline) - mandatory: PROFILE, ENVIRONMENT, SHARED_ENVIRONMENT, BLUE_GREEN_ENVIRONMENT + eval "$$(make -s populate-tagging-variables)" + make terraform-apply-auto-approve STACKS=application + +build-and-deploy-blue-green-environment: # Deploys blue/green resources - mandatory: PROFILE, ENVIRONMENT, SHARED_ENVIRONMENT, BLUE_GREEN_ENVIRONMENT + make build-and-push VERSION=$(BUILD_TAG) + make deploy-blue-green-environment VERSION=$(BUILD_TAG) + +link-blue-green-environment: # Links blue green environment - mandatory: PROFILE, ENVIRONMENT, SHARED_ENVIRONMENT, BLUE_GREEN_ENVIRONMENT + eval "$$(make -s populate-tagging-variables)" + make terraform-apply-auto-approve STACKS=blue-green-link + +undeploy-shared-resources: # Undeploys shared resources (Only intended to run in pipeline) - mandatory: PROFILE, ENVIRONMENT, SHARED_ENVIRONMENT, BLUE_GREEN_ENVIRONMENT + eval "$$(make -s populate-tagging-variables)" + make terraform-destroy-auto-approve STACKS=shared-resources + if [ "$(PROFILE)" != "live" ]; then + make terraform-destroy-auto-approve STACKS=api-key + fi + +undeploy-blue-green-environment: # Undeploys blue/green resources (Only intended to run in pipeline) - mandatory: PROFILE, ENVIRONMENT, SHARED_ENVIRONMENT, BLUE_GREEN_ENVIRONMENT + eval "$$(make -s populate-tagging-variables)" + make terraform-destroy-auto-approve STACKS=application + +unlink-blue-green-environment: # Un-Links blue green environment - mandatory: PROFILE, ENVIRONMENT, SHARED_ENVIRONMENT, BLUE_GREEN_ENVIRONMENT + eval "$$(make -s populate-tagging-variables)" + make terraform-destroy-auto-approve STACKS=blue-green-link + +tag-commit-to-deploy-blue-green-environment: # Tags commit to deploy blue/green environment - mandatory: COMMIT=[short commit hash] + tag="$(BUILD_TIMESTAMP)-blue-green-deployment" + make git-tag-create TAG=$$tag COMMIT=$(COMMIT) + +tag-commit-to-deploy-shared-resources: # Tags commit to deploy shared resources - mandatory: COMMIT=[short commit hash] + tag="$(BUILD_TIMESTAMP)-shared-resources-deployment" + make git-tag-create TAG=$$tag COMMIT=$(COMMIT) + +tag-commit-to-rollback-blue-green-environment: # Tags commit to rollback blue/green environment - mandatory: PROFILE=[name], SHARED_ENVIRONMENT=[name] + tag="$(BUILD_TIMESTAMP)_$(PROFILE)_$(SHARED_ENVIRONMENT)_blue_green_rollback" + make git-tag-create TAG=$$tag COMMIT=$(COMMIT) + +# ============================================================================== +# Pipeline Targets + +commit-date-hash-tag: + echo "$(BUILD_COMMIT_DATETIME)-$(BUILD_COMMIT_HASH)" + +docker-run-tester: ### Run python container - mandatory: CMD; optional: SH=true,DIR,ARGS=[Docker args],LIB_VOLUME_MOUNT=true,VARS_FILE=[Makefile vars file],IMAGE=[image name],CONTAINER=[container name] + make docker-config > /dev/null 2>&1 + mkdir -p $(TMP_DIR)/.python/pip/{cache,packages} + lib_volume_mount=$$(([ $(BUILD_ID) -eq 0 ] || [ "$(LIB_VOLUME_MOUNT)" == true ]) && echo "--volume $(TMP_DIR)/.python/pip/cache:/tmp/.cache/pip --volume $(TMP_DIR)/.python/pip/packages:/tmp/.packages" ||:) + container=$$([ -n "$(CONTAINER)" ] && echo $(CONTAINER) || echo tester-$(BUILD_COMMIT_HASH)-$(BUILD_ID)-$$(date --date=$$(date -u +"%Y-%m-%dT%H:%M:%S%z") -u +"%Y%m%d%H%M%S" 2> /dev/null)-$$(make secret-random LENGTH=8)) + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ + --name $$container \ + --user $$(id -u):$$(id -g) \ + --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM)") \ + --env-file <(make _list-variables PATTERN="^(DB|DATABASE|SMTP|APP|APPLICATION|UI|API|SERVER|HOST|URL)") \ + --env-file <(make _list-variables PATTERN="^(PROFILE|ENVIRONMENT|BUILD|PROGRAMME|ORG|SERVICE|PROJECT)") \ + --env-file <(make _docker-get-variables-from-file VARS_FILE=$(VARS_FILE)) \ + --env HOME=/tmp \ + --env PIP_TARGET=/tmp/.packages \ + --env PYTHONPATH=/tmp/.packages \ + --env XDG_CACHE_HOME=/tmp/.cache \ + --volume $(PROJECT_DIR):/project \ + --volume $(HOME)/.aws:/tmp/.aws \ + --volume $(HOME)/bin:/tmp/bin \ + --volume $(HOME)/etc:/tmp/etc \ + --volume $(HOME)/usr:/tmp/usr \ + $$lib_volume_mount \ + --network $(DOCKER_NETWORK) \ + --workdir /project/$(shell echo $(abspath $(DIR)) | sed "s;$(PROJECT_DIR);;g") \ + $(ARGS) \ + $$(make _docker-get-reg)/tester:latest \ + $(CMD) + + +# ============================================================================== +# Python Targets (including Ruff) + +python-code-checks: + make python-check-dead-code + make python-run-ruff-checks + make unit-test + echo "Python code checks completed" + +python-check-dead-code: + make -s docker-run-python \ + IMAGE=$$(make _docker-get-reg)/tester:latest \ + DIR=$(APPLICATION_DIR) \ + CMD="python -m vulture" + +docker-run-ruff: # Runs ruff tests - mandatory: RUFF_OPTS=[options] + make -s docker-run \ + IMAGE=$$(make _docker-get-reg)/tester \ + CMD="ruff $(RUFF_OPTS)" + +python-run-ruff-checks: # Auto fixes ruff warnings + make -s docker-run-ruff RUFF_OPTS="format --check ." + make docker-run-ruff RUFF_OPTS="check ." + +python-run-ruff-fixes: # Auto fixes ruff warnings + make -s docker-run-ruff RUFF_OPTS="format . " + make docker-run-ruff RUFF_OPTS="check . --fix" + +# ============================================================================== + +.SILENT: docker-run-ruff \ + commit-date-hash-tag \ + performance-test-results \ + container-check \ diff --git a/README.md b/README.md index b8b321e78..2d3c0e795 100644 --- a/README.md +++ b/README.md @@ -5,31 +5,48 @@ - [DoS Integration](#dos-integration) - [Table of Contents](#table-of-contents) - [Overview](#overview) + - [DI Confluence Page](#di-confluence-page) + - [Architecture](#architecture) + - [Technology Stack](#technology-stack) - [Quick Start](#quick-start) - [Development Requirements](#development-requirements) - - [Local Environment Configuration](#local-environment-configuration) - - [Local Project Setup](#local-project-setup) + - [Clone Repository](#clone-repository) + - [AWS Authentication](#aws-authentication) + - [Mac setup](#mac-setup) - [Contributing](#contributing) - [Development](#development) - [Add IP Address to IP Allow List](#add-ip-address-to-ip-allow-list) - - [Database Connection](#database-connection) - - [Code Formatting](#code-formatting) - - [Code Quality](#code-quality) + - [DoS Database Connection](#dos-database-connection) + - [Python Code Formatting \& Quality](#python-code-formatting--quality) - [Testing](#testing) - [Unit Testing](#unit-testing) + - [Where are the unit tests run?](#where-are-the-unit-tests-run) - [Integration Testing](#integration-testing) + - [Where are the integration tests run?](#where-are-the-integration-tests-run) - [Performance Testing](#performance-testing) + - [Where are the performance tests run?](#where-are-the-performance-tests-run) + - [Collecting Performance Test Results](#collecting-performance-test-results) - [Test data and mock services](#test-data-and-mock-services) - - [Manual check](#manual-check) - - [Extra test to check lambda access to DoS database read replica](#extra-test-to-check-lambda-access-to-dos-database-read-replica) - [General Deployment](#general-deployment) - [API Key](#api-key) - - [Artefact Versioning](#artefact-versioning) + - [Artefacts Versioning](#artefacts-versioning) + - [Docker Images](#docker-images) - [CI/CD Pipelines](#cicd-pipelines) - [Deployment From the Command-line](#deployment-from-the-command-line) + - [Branching Strategy](#branching-strategy) - [Branch Naming for Automatic Deployments](#branch-naming-for-automatic-deployments) - [Branch Naming to not automatically deploy](#branch-naming-to-not-automatically-deploy) - - [Quick Deployment](#quick-deployment) + - [Blue/Green Deployments](#bluegreen-deployments) + - [Blue/Green Deployment Strategy](#bluegreen-deployment-strategy) + - [Blue/Green Deployment Process](#bluegreen-deployment-process) + - [Useful Blue/Green Deployment Commands](#useful-bluegreen-deployment-commands) + - [Update shared resources](#update-shared-resources) + - [Trigger Blue/Green Deployment Pipeline](#trigger-bluegreen-deployment-pipeline) + - [Trigger Shared Resources Deployment Pipeline](#trigger-shared-resources-deployment-pipeline) + - [Undeploy Blue/Green Environment](#undeploy-bluegreen-environment) + - [Undeploy Shared Resources Environment](#undeploy-shared-resources-environment) + - [Rollback Blue/Green Environment](#rollback-bluegreen-environment) + - [Quick Re-deploy](#quick-re-deploy) - [Remove Deployment From the Command-line](#remove-deployment-from-the-command-line) - [Remove deployment with commit tag](#remove-deployment-with-commit-tag) - [Remove deployment on Pull Request merge](#remove-deployment-on-pull-request-merge) @@ -37,31 +54,10 @@ - [AWS Access](#aws-access) - [Production Deployment](#production-deployment) - [Prerequisites](#prerequisites) - - [How to deploy](#how-to-deploy) - - [Example](#example) - - [Architecture](#architecture) - - [Diagrams](#diagrams) - - [System Context Diagram](#system-context-diagram) - - [Container Diagram](#container-diagram) - - [Component Diagram](#component-diagram) - - [Processes and Data Flow](#processes-and-data-flow) - - [Infrastructure](#infrastructure) - - [Networking](#networking) - - [Integration](#integration) - - [Interfaces](#interfaces) - - [Dependencies](#dependencies) - - [Data](#data) - - [Authentication and Authorisation](#authentication-and-authorisation) - - [Technology Stack](#technology-stack) - - [Key Architectural Decisions](#key-architectural-decisions) - - [System Quality Attributes](#system-quality-attributes) - [Guiding Principles](#guiding-principles) - [Operation](#operation) - - [Error Handling](#error-handling) - [Observability](#observability) - [Tracing Change events and requests Correlation Id](#tracing-change-events-and-requests-correlation-id) - - [Auditing](#auditing) - - [Backups](#backups) - [Cloud Environments](#cloud-environments) - [Runbooks](#runbooks) - [Product](#product) @@ -70,100 +66,127 @@ ## Overview -A few sentences what business problem this project solves... +The NHS.uk website, and the DoS (Directory of Services) service are separate entities which both store a lot of the same important data about Pharmacies/Dentists and other health organisations around the UK. The management of these individual services therefore needs to update information in multiple places online to keep their data fully up to date for their users. + +The DoS Integration project aims to keep any updates made on NHS.uk consistent with DoS by comparing any updates and creating any change requests needed to keep the information up to date. + +### DI Confluence Page + + + +### Architecture + +![Architecture](./documentation/diagrams/DoS%20Integration-Components.drawio.png) + +### Technology Stack + +The current technology stack is: + +- Python - Main programming language +- AWS: Lambda, DynamoDB, API Gateway, CodePipeline, KMS, SQS, S3 +- Terraform ## Quick Start ### Development Requirements -- macOS operating system provisioned with the `curl -L bit.ly/make-devops-macos-setup | bash` command -- `iTerm2` command-line terminal and `Visual Studio Code` source code editor, which will be installed automatically for you in the next steps +It is recommended to use either a macOS or Linux. If using a Windows machine it is highly recommended to run a VM using WSL2 to create a Linux environment to work with. Try not to use the Windows command line. -### Local Environment Configuration +A mac is no longer required for basic development since task branches are automatically built on the push of a new commit. However the build/deploy commands currently are only designed to work with macOS. + +This project contains a macOS environment which can be installed and setup that gives the user a wide range of tools useful for development. More info on this is in the mac setup section. + +The main components you will need for _basic_ development work, are your OS version of the below. + +- A VPN Client (OpenVPN or Tunnelblick are 2 NHS Digital suggested options) +- Git +- Python (The project currently runs on 3.12) +- AWS CLI +- Docker + +### Clone Repository Clone the repository - git clone [project-url] - cd ./[project-dir] + git clone git@github.com:nhsd-exeter/dos-integration.git + cd ./dos-integration -The following is equivalent to the `curl -L bit.ly/make-devops-macos-setup | bash` command. If that step has already been done it can be omitted at this point +### AWS Authentication - make macos-setup +Please, ask one of your colleagues for the AWS account numbers used by the project. You will use these as roles which you will assume from your account. -There are essential configuration options that **must** be set before proceeding any further. As a minimum the following command will ensure that tooling like `docker` and `git` are going to operate as expected, including local secret scanning and code formatting are enabled. Make sure you have `tx-mfa` into non-prod first before running `make setup` +Instructions and tips for basic authentication for AWS can be found online. Any method that lets you authenticate and assume roles will work with this project. + - make setup +There is also an automated method to setup AWS access within the mac setup. Once the mac step up scripts have been run, the following command can be used to choose and switch between AWS roles automatically. -Please, ask one of your colleagues for the AWS account numbers used by the project. The next command will prompt you to provide them. This information can be sourced from a properly set up project by running `make show-configuration | grep ^AWS_ACCOUNT_ID_` + assume - make devops-setup-aws-accounts +### Mac setup + +The following is equivalent to the `curl -L bit.ly/make-devops-macos-setup | bash` command. -Generate and trust a self-signed certificate that will be used locally to enable encryption in transit + make macos-setup - make trust-certificate +There are configuration options that should be set before proceeding. The following command will ensure that tooling like `docker` and `git` are going to operate as expected, including local secret scanning and code formatting are enabled. Make sure you authenticated in the AWS Non-Prod account first before running `make setup` -### Local Project Setup + make setup - # Terminal 1 - make build - make start log +Please, ask one of your colleagues for the AWS account numbers used by the project. The next command will prompt you to provide them. This information can be sourced from a properly set up project by running `make show-configuration | grep ^AWS_ACCOUNT_ID_` + + make devops-setup-aws-accounts ## Contributing Here is the list of the development practices that have to be followed by the team and the individual members: -- Only use single canonical branch **master**. Any intermediate branch significantly increases the maintenance overhead of the repository. -- Apply the git rebase workflow and never merge from master to a task branch. Follow the **squash-rebase-merge** pattern to keep the history linear and clean. -- Cryptographically sign your commits using **gpg** to ensure its content have not been tampered with. +- Only use single canonical branch **develop**. Any intermediate branch significantly increases the maintenance overhead of the repository. +- Apply the git rebase workflow and never merge from develop to a task branch. Follow the **squash-rebase-merge** pattern to keep the history linear and clean. +- Cryptographically sign your commits using **GPG** to ensure its content have not been tampered with. - Format the summary message of your pull request (merge request) using the following pattern **"JIRA-XXX Summary of the change being made"** for complies and clarity as well as to enable tooling to produce release notes automatically. - Announce your PR/MR on the development Slack channel to allow any team member to review it and to share the knowledge. A change can be merged only if all comments have been addressed and it has been **approved by at least one peer**. Make good use of paring/mobbing/swarming practices for collaborative coding. -Before starting any work, please read [CONTRIBUTING.md](documentation/CONTRIBUTING.md) for more detailed instructions. +Before starting any work, please read [Contributing.md](./documentation/Contributing.md) for more detailed instructions. ## Development ### Add IP Address to IP Allow List -Prerequisites (first setup only) +To find your public IP you can visit - make tester-build +An IP Allowlist is kept in secrets manager for each environment (dev, demo, live, etc). The Secret Name for each is of the format -Requirements to update IP allow list (Every time) + uec-dos-int-XXXX-ip-addresses-allowlist - tx-mfa +where XXXX is the name of the environment in lowercase. For most development work you only need to add your IP to the dev environments list. -To add an IP address to the IP allow lists, run the following command. +You can also add your IP to the lists with a script. - make update-all-ip-allowlists +Prerequisites (first setup only) -To add an IP address to the IP allow lists and deploy the allow list to environment run the following command.The `PROFILE` delineates which environment to update with the latest IP allow list. Set `ENVIRONMENT` if you are changing an environment not linked to your branch + make tester-build + +To add an IP address to the IP allow lists, Ensure you're authenticated for access to AWS and run the following command. - make update-ip-allowlists-and-deploy-allowlist PROFILE=task + make update-all-ip-allowlists -Note: IP Addresses are held in the AWS Secrets Manager with the secret name being the variable `TF_VAR_ip_address_secret`. You can find your IP address in the AWS console with your Git username as the key and the IP address as the value +To add an IP address to the IP allow lists and deploy the allow list to environment run the following command.The `PROFILE` delineates which environment to update with the latest IP allow list. Set `ENVIRONMENT` if you are changing an environment not linked to your branch -### Database Connection + make update-ip-allowlists-and-deploy-allowlist PROFILE=dev -To connect to the local postgres database use these connection +### DoS Database Connection - Host = localhost - Port = 5432 - Database = postgres - Username = postgres - Password = postgres - Schema = postgres +The following vars are required for the project to establish a connection to the DoS writer database (or a Reader). +`Host, Port, Database, Username, Password, Schema` +These variable will be stored in AWS Secrets Manager and will be retrieved by the project at either deployment or runtime. -### Code Formatting +### Python Code Formatting & Quality - To format the code run: - make python-code-format FILES=./application - make python-code-format FILES=./test +Python code is required be formatted and linted by Ruff. -### Code Quality +To run ruff on you branch: - To check the code quality run: - make python-code-check FILES=./application - make python-code-check FILES=./test + make python-run-ruff-fixes ## Testing @@ -171,19 +194,13 @@ List all the type of test suites included and provide instructions how to execut - Unit - Integration -- Contract -- End-to-end - Performance -- Security -- Smoke - -How to run test suite in the pipeline ### Unit Testing Unit testing is to test the functions within each lambda. This testing is done on the local system to where the commands are run e.g CLI, CI/CD Pipelines -This includes:This testing includes: +This includes: - Function calls - Correct data types and data returned from function @@ -200,13 +217,21 @@ For coverage run make coverage-report +The unit tests are run using pytest and coverage (both available to download via pip). If you want to run the unit tests without the setup, or want to target only certain files/folders you can run the tests in your own environment directly by going to the /application directory and running. + + python3 -m pytest --cov=. -vv + +#### Where are the unit tests run? + +The unit tests are run in multiple places. They are developed and run locally. They are also run in GitHub Actions on each pull request and commit on develop. The unit tests are also run in the development pipeline on each deployment merge into develop. + ### Integration Testing -Integration Testing is to test the functional capabilities of the individual component work together with mocks and partner services. Asserting that individual components can work in harmony together achieving the overall business goals. This testing is done on AWS to test the connection between components. +Integration Testing is used to test the functional capabilities of the individual component work together with mocks and partner services. Asserting that individual components can work in harmony together, achieving the overall business goals. This testing is done on AWS to test the connection between components. This testing includes: -- Limited use of Mocks +- No Mocking. Except Emails which are mocked in NonProd - Check data when passed between components - Meets business needs of the application @@ -214,13 +239,26 @@ This testing is generally done by a tester Prerequisites - tx-mfa - Sign into Non-Prod VPN + assume # Granted assume AWS Role + Sign into Non-Prod VPN # To connect to lambdas within the VPC + IP is in the IP Allow List # To connect to the API Gateway make tester-build To run unit tests run the following commands - make integration-test PROFILE=task TAGS=dev PARALLEL_TEST_COUNT=10 + make integration-test PROFILE=dev TAG=complete PARALLEL_TEST_COUNT=10 + +Tests are currently separated into many tags. These tags are used to run the tests in parallel. The tags are as follows: + +- general: Tests that do not fall into other groups +- validation: Tests that validate incorrect data is not processed +- slack_and_infrastructure: Tests that validate slack alerts and infrastructure (including quality checker tests) +- reporting: Tests that validate reporting +- opening_times: Tests that check/update opening times + +#### Where are the integration tests run? + +Integration tests are run locally against development environments. They are also run in the development pipeline on each deployment merge into develop. ### Performance Testing @@ -232,14 +270,14 @@ This testing includes: This testing is generally done by a tester -To run the performance tests run the following commands after you have run `tx-mfa` to sign into Non-Prod +To run the performance tests run the following commands after you have run `assume` to sign into Non-Prod To run a stress test make tester-build make stress-test PROFILE=perf ENVIRONMENT=perf START_TIME=$(date +%Y-%m-%d_%H-%M-%S) - Wait for the test to complete - make performance-test-data-collection PROFILE=perf ENVIRONMENT=perf START_TIME=[Start Time from above command] END_TIME=$(date +%Y-%m-%d_%H-%M-%S) + # Wait for the test to complete + # Collect data from performance testing Note: if you have any errors consider reducing number of users or increasing the docker resources @@ -247,213 +285,231 @@ To run a load test make tester-build make load-test PROFILE=perf ENVIRONMENT=perf START_TIME=$(date +%Y-%m-%d_%H-%M-%S) - Wait for the test to complete - make performance-test-data-collection PROFILE=perf ENVIRONMENT=perf START_TIME=[Start Time from above command] END_TIME=$(date +%Y-%m-%d_%H-%M-%S) + # Wait for the test to complete + # Collect data from performance testing -### Test data and mock services +#### Where are the performance tests run? -- How the test data set is produced -- Are there any mock services in place +Performance tests are run locally against development environments. They are also run in the performance AWS CodeBuild stages adhoc against the performance environment. -### Manual check +#### Collecting Performance Test Results -Here are the steps to perform meaningful local system check: +Performance Test results can be collected -- Log in to the system using a well known username role + make performance-test-results PROFILE= ENVIRONMENT= START_TIME=[timestamp], END_TIME=[timestamp] -### Extra test to check lambda access to DoS database read replica +Example -A make target has been added to check that a lambda can successful access the dos database read replica in non-prod. The target currently intend to be used to test the event processor. It can be run using the following: + make performance-test-results PROFILE=perf ENVIRONMENT=perf START_TIME=2023-11-28T10:00:00Z END_TIME=2023-11-28T12:00:00Z - make test-deployed-event-processor-db-connection LAMBDA_NAME=uec-dos-int-di-203-event-processor +For more details review the make target documentation with the make file -It will return a error a code if it hasn't worked successfully. It use the json in the file in `test/common` as a payload. The file contains an example change event with a service that exists in the replica database. +### Test data and mock services + +- How the test data set is produced +- Are there any mock services in place ## General Deployment ### API Key -API Key(s) must be generated prior to external API-Gateways being set up. It is automatically created when deploying with `make deploy PROFILE=task`. However the dev, demo and live profiles' key must be manually generated prior to deployment. +API Key(s) must be generated prior to external API-Gateways being set up. It is automatically created when deploying with `make deploy PROFILE=dev`. However the dev, demo and live profiles' key must be generated prior to deployment of the API gateway. -### Artefact Versioning +### Artefacts Versioning -E.g. semantic versioning vs. timestamp-based +Releases are semantically versioned using the following format: -### CI/CD Pipelines + MAJOR.MINOR + +All standard releases are considered major releases. Minor releases are used for hotfixes. -List all the pipelines and their purpose +Deployment images are instead tagged with the commit hash of the commit it was built from. Standard non deployment images are tagged with the timestamp and commit hash of the commit they were built from. -- Development -- Test -- Cleanup -- Production (deployment) +#### Docker Images -Reference the [jenkins/README.md](build/automation/lib/jenkins/README.md) file +Docker images for releases are tagged with the version of the pipeline. -

+However in the task deploy and test CodeBuild uses a timestamp and commit hash tag. + +### CI/CD Pipelines + +![CI/CD Pipelines](./documentation/diagrams/DevOps-Pipelines%20and%20Automation.drawio.png) + +All `test` CodeBuild automations can be found in the AWS CodePipeline/CodeBuild areas in the `Texas` `MGMT` account. + +More information can be found on DoS Integration's confluence workspace ### Deployment From the Command-line - make build-and-deploy PROFILE=task # Builds docker images, pushes them and deploys to lambda + make build-and-deploy PROFILE=dev # Builds docker images, pushes them and deploys to lambda + +### Branching Strategy + +More information can be found on DoS Integration's confluence workspace + +![Branching Strategy](./documentation/diagram/../diagrams/DoS%20Integration-GitHub.drawio.png) ### Branch Naming for Automatic Deployments -For a branch to be automatically deployed on every push the branch must be prefixed with `task`. This will then be run on an AWS Codebuild stage to deploy the code to a task environment. e.g `task/DI-123_My_feature_branch` +For a branch to be automatically deployed on every push the branch must be prefixed with `task`. This will then be run on an AWS CodeBuild stage to deploy the code to a task environment. e.g `task/DS-123_My_feature_branch` -Once a branch which meets this criteria has been pushed the it will run a build and deployment for the environment and notify the dos-integration-dev-status channel with the status of your deployment. +Once a branch which meets this criteria has been pushed then it will run a build and deployment for the environment and notify the dos-integration-dev-status channel with the status of your deployment. ### Branch Naming to not automatically deploy -For a branch that is meant for testing or another purpose and you don't want it to deploy on every push to the branch. It must be prefixed with one of these `spike|automation|test|bugfix|hotfix|fix|release|migration`. e.g. `fix/DI-123_My_fix_branch` +For a branch that is meant for testing or another purpose and you don't want it to deploy on every push to the branch. It must be prefixed with one of these `spike|automation|test|bugfix|hotfix|fix|release|migration`. e.g. `fix/DS-123_My_fix_branch` -#### Quick Deployment +## Blue/Green Deployments -To quick update the lambdas run the following command. Note this only updates the lambdas and api-gateway +### Blue/Green Deployment Strategy - make sls-only-deploy PROFILE=task VERSION=latest +To deploy a new version of the application in a blue green way it uses multiple components. Such as resources that should persist between deployments, such as the database, and resources that should be recreated with each deployment, such as the lambda functions. -### Remove Deployment From the Command-line +![Blue/Green Deployment Strategy](./documentation/diagrams/DoS%20Integration-Blue-Green-Deployments.drawio.png) - make undeploy PROFILE=task # Builds docker images, pushes them and deploys to lambda +### Blue/Green Deployment Process -### Remove deployment with commit tag +This guide will walk you through the steps to deploy a new version of the application in a blue green way. It assumes you have already deployed the application once and have a blue environment. -You can remove a task deployment using a single command to create a tag which then runs an AWS codebuild stage that will undeploy that environment +This guide will use the following environment names: +Live - The Shared Environment +gggggg - Commit Hash for the Green New Blue/Green Environment. +bbbbbb - Commit Hash for the Blue Current Blue/Green Environment - make tag-commit-to-destroy-environment ENVIRONMENT=[environment to destroy] COMMIT=[short commit hash] - e.g. make tag-commit-to-destroy-environment ENVIRONMENT=di-363 COMMIT=2bc43dd // This destroys the di-363 task environment +1. Create a new blue/green environment with the new version. This creates a new blue/green environment ready to be switched to. -### Remove deployment on Pull Request merge + make deploy-blue-green-environment PROFILE=[live/demo] ENVIRONMENT=[blue-green-environment(short-commit-hash)] VERSION=[s3-file-version] SHARED_ENVIRONMENT=[shared-resources-environment] BLUE_GREEN_ENVIRONMENT=[blue-green-environment(short-commit-hash)] -When a pull request is merged it will run an AWS Codebuild project that will destroy the environment if it exists. -The codebuild stage can be found within the development-pipeline terraform stack. + - Example + make deploy-blue-green-environment PROFILE=live ENVIRONMENT=ggggggg VERSION=[s3-file-version] SHARED_ENVIRONMENT=[live] BLUE_GREEN_ENVIRONMENT=[ggggg] -### Secrets +2. Unlink the current blue/green environment from the shared resources. This will remove any links between the blue/green environment and the shared resources. -Where are the secrets located, i.e. AWS Secrets Manager, under the `$(PROJECT_ID)-$(PROFILE)/deployment` secret name and variable `$(DEPLOYMENT_SECRETS)` should be set accordingly. + make unlink-blue-green-environment PROFILE=[live/demo] ENVIRONMENT=[shared-resources-environment] SHARED_ENVIRONMENT=[shared-resources-environment] BLUE_GREEN_ENVIRONMENT=[blue-green-environment(short-commit-hash)] TF_VAR_previous_blue_green_environment=[OPTIONAL: current-blue-green-environment(short-commit-hash)] -### AWS Access - -To be able to interact with a remote environment, please make sure you have set up your AWS CLI credentials and -MFA to the right AWS account using the following command + - Example + make unlink-blue-green-environment PROFILE=live ENVIRONMENT=live SHARED_ENVIRONMENT=live BLUE_GREEN_ENVIRONMENT=ggggggg TF_VAR_previous_blue_green_environment=bbbbbbb - tx-mfa +3. Link the new blue/green environment to the shared resources. This will link the new blue/green environment to the shared resources. -## Production Deployment + make link-blue-green-environment PROFILE=[live/demo] ENVIRONMENT=[shared-resources-environment] BLUE_GREEN_ENVIRONMENT=[new-blue-green-environment] -### Prerequisites + - Example + make link-blue-green-environment PROFILE=live ENVIRONMENT=live BLUE_GREEN_ENVIRONMENT=gggggg -The production pipeline terraform stack must be deployed +### Useful Blue/Green Deployment Commands - make deploy-deployment-pipelines PROFILE=tools ENVIRONMENT=dev +#### Update shared resources -### How to deploy +To update the shared resources run the following command. +Note: The shared environment must be unlinked from the blue/green environment before running this command. Then the blue/green environment must be linked to the shared environment after running this command. -To deploy an update/new version to a production environment the commit must be tagged using the command below. This will automatically run a Github web hook that will trigger an AWS Codebuild project that will deploy the environment based on the git tag. + make deploy-shared-resources PROFILE=[live/demo] ENVIRONMENT=[shared-resources-environment] SHARED_ENVIRONMENT=[shared-resources-environment] -Note: This should only be run against a commit on the master branch as the code has been built into an image and pushed to ECR. Also short commit hash is the first 7 characters of the commit hash. + - Example + make deploy-shared-resources PROFILE=live ENVIRONMENT=live SHARED_ENVIRONMENT=live -To Deploy Demo +#### Trigger Blue/Green Deployment Pipeline - make tag-commit-for-deployment PROFILE=demo ENVIRONMENT=demo COMMIT=[short commit hash] +This will trigger the blue/green deployment pipeline to deploy the commit hash to the blue/green environment in the MGMT account. +The AWS CodePipeline name will be `uec-dos-int-dev-cicd-blue-green-deployment-pipeline` -To Deploy Live +COMMIT should be the commit hash of the commit you want to deploy. +This should only be done from main branch. - make tag-commit-for-deployment PROFILE=live ENVIRONMENT=live COMMIT=[short commit hash] +An approval stage stops this command from automatically deploying to Live. But it will automatically apply to a dev and a demo environment. -#### Example + make tag-commit-to-deploy-blue-green-environment COMMIT=[short-commit-hash] - make tag-commit-for-deployment PROFILE=demo ENVIRONMENT=demo COMMIT=1b4ef5a + - Example + make tag-commit-to-deploy-blue-green-environment COMMIT=ggggggg -## Architecture +#### Trigger Shared Resources Deployment Pipeline -### Diagrams +This will trigger the shared resources deployment pipeline to deploy the commit hash to the shared resources environment in the MGMT account. +The AWS CodePipeline name will be `uec-dos-int-dev-cicd-shared-resources-deployment-pipeline` -#### System Context Diagram +COMMIT should be the commit hash of the commit you want to deploy. +This should only be done from main branch. -Include an image of the [C4 model](https://c4model.com/) System Context diagram exported as a `.png` file from the draw.io application. +An approval stage stops this command from automatically deploying to Live. But it will automatically apply to a dev and a demo environment. -

+ make tag-commit-to-deploy-shared-resources COMMIT=[short-commit-hash] -#### Container Diagram + - Example + make tag-commit-to-deploy-shared-resources COMMIT=ggggggg -Include an image of the [C4 model](https://c4model.com/) Container diagram exported as a `.png` file from the draw.io application. +#### Undeploy Blue/Green Environment -

+This will remove the blue/green environment and is intended to be used when the blue/green rollback environment is no longer needed. -#### Component Diagram +Note: If the blue/green environment is linked to the shared resources environment then it must be unlinked before running this command. -Include an image of the [C4 model](https://c4model.com/) Component diagram exported as a `.png` file from the draw.io application. + make undeploy-blue-green-environment PROFILE=[live/demo] ENVIRONMENT=[blue-green-environment] SHARED_ENVIRONMENT=[shared-resources-environment] BLUE_GREEN_ENVIRONMENT=[blue-green-environment] + - Example + make tag-commit-to-deploy-blue-green-environment COMMIT=ggggggg -

+#### Undeploy Shared Resources Environment -#### Processes and Data Flow +This will remove the shared resources environment and is intended to be used when the shared resources environment is no longer needed. -Include an image of the Processes and Data Flow diagram +Note: No blue/green environments can exist for this shared resources environment when running this command. +If they do the blue/green environments must be unlinked and undeployed first. -#### Infrastructure + make undeploy-shared-resources PROFILE=[live/demo] ENVIRONMENT=[blue-green-environment] SHARED_ENVIRONMENT=[shared-resources-environment] BLUE_GREEN_ENVIRONMENT=[blue-green-environment] -Include an image of the Infrastructure diagram. Please, be aware that any sensitive information that can be potentially misused either directly or indirectly must not be stored and accessible publicly. This could be IP addresses, domain names or detailed infrastructure information. + - Example + make undeploy-shared-resources PROFILE=live ENVIRONMENT=live SHARED_ENVIRONMENT=live BLUE_GREEN_ENVIRONMENT=ggggggg -

+#### Rollback Blue/Green Environment -#### Networking +This will rollback the blue/green environment to the previous version. It's best to use the commit of the version you are intending to rollback to ensure the Terraform works correctly together. -Include an image of the Networking diagram. Please, be aware that any sensitive information must not be stored and accessible publicly. This could be IP addresses, domain names or detailed networking information. + make rollback-blue-green-environment PROFILE=[live/demo/dev] SHARED_ENVIRONMENT=[shared-resources-environment] COMMIT=[short-commit-hash] -### Integration + - Example + make tag-commit-to-rollback-blue-green-environment PROFILE=dev SHARED_ENVIRONMENT=cicd-test COMMIT=c951156 -#### Interfaces +### Quick Re-deploy -Document all the system external interfaces +To quick update the lambdas run the following command. Note this only updates the lambdas -- API documentation should be generated automatically + make quick-build-and-deploy PROFILE=dev ENVIRONMENT=ds-123 # Environment is optional if your branch is prefixed with task/DS-xxx -#### Dependencies +### Remove Deployment From the Command-line -Document all the system external dependencies and integration points + make undeploy PROFILE=dev # Builds docker images, pushes them and deploys to lambda -### Data +### Remove deployment with commit tag -What sort of data system operates on and processes +You can remove a dev deployment using a single command to create a tag which then runs an AWS CodeBuild project that will remove that environment -- Data set -- Consistency and integrity -- Persistence + make tag-commit-to-destroy-environment ENVIRONMENT=[environment to destroy] COMMIT=[short commit hash] + e.g. make tag-commit-to-destroy-environment ENVIRONMENT=ds-363 COMMIT=2bc43dd // This destroys the ds-363 dev environment -### Authentication and Authorisation +### Remove deployment on Pull Request merge -- Default user login for testing -- Different user roles -- Authorisation type -- Authentication method +When a pull request is merged it will run an AWS CodeBuild project that will destroy the environment if it exists. +The AWS CodeBuild project can be found within the development-pipeline Terraform stack. -It is recommended that any other documentation related to the aspect of security should be stored in a private workspace. +### Secrets -### Technology Stack +Where are the secrets located, i.e. AWS Secrets Manager, under the `$(PROJECT_ID)-$(PROFILE)/deployment` secret name and variable `$(DEPLOYMENT_SECRETS)` should be set accordingly. -What are the technologies and programming languages used to implement the solution +### AWS Access -The current technology stack is: +To be able to interact with a remote environment, please make sure you have set up your AWS CLI credentials and +Assume the right AWS account using the following command -- Python (typically latest version) - Main programming language -- Serverless Framework - Infrastructure as code tool (we use where possible) -- Terraform - Infrastructure as code tool (we use when infrastructure is not supported by Serverless Framework) + assume -### Key Architectural Decisions +## Production Deployment -Architectural decisions records (ADRs) are stored in `documentation/adr` +### Prerequisites -### System Quality Attributes +The pipelines Terraform stack must be deployed -- Accessibility, usability -- Resilience, durability, fault-tolerance -- Scalability, elasticity -- Consistency -- Performance -- Interoperability -- Security -- Supportability + make deploy-development-and-deployment-tools ENVIRONMENT=dev ### Guiding Principles @@ -469,10 +525,6 @@ List of the high level principles that a product /development team must adhere t ## Operation -### Error Handling - -- What is the system response under the erroneous conditions - ### Observability - Logging @@ -480,7 +532,7 @@ List of the high level principles that a product /development team must adhere t - Format - Tracing - AWS X-Ray Trace Ids (These are included in logs) - - `correlation-id` and `reference` (dos) provide a common key to track change events across systems: NHS UK Profile Editor, DoS Integrations, and DoS (Api Gateway) + - `correlation-id` and `reference` provide a common key to track change events across systems: NHS UK Profile Editor and DoS Integration - Monitoring - Dashboards - Alerting @@ -493,37 +545,30 @@ What are the links of the supporting systems? #### Tracing Change events and requests Correlation Id - To be able to track a change event and the change requests it can become across systems a common id field is present on logs related to each event. The id is generated in `Profile Editor` (NHS UK) which is then assigned to the `correlation-id` header of the request send to our (DoS Integration) endpoint, for a given change event. The `correlation-id` header is then used throughout the handling of the change event in `DoS Integration`. - - If a change event does result in change requests being created for `DoS` then the change requests have a `reference` key with the value being the correlation id. +To be able to track a change event and the change requests it can become across systems a common id field is present on logs related to each event. The id is generated in `Profile Editor` (NHS UK) which is then assigned to the `correlation-id` header of the request send to our (DoS Integration) endpoint, for a given change event. The `correlation-id` header is then used throughout the handling of the change event in `DoS Integration`. - The events can be further investigated in DoS Integration process by using the X-Ray trace id that is associated with the log that has the correlation id. +If a change event does result in change requests being created for `DoS` then the change requests have a `reference` key with the value being the correlation id. -### Auditing - -Are there any auditing requirements in accordance with the data retention policies? - -### Backups - -- Frequency and type of the backups -- Instructions on how to recover the data +The events can be further investigated in DoS Integration process by using the X-Ray trace id that is associated with the log that has the correlation id. ### Cloud Environments -List all the environments and their relation to profiles +List all the profiles - Dev - Profile: `dev` + - Used for development, testing and integration. This is the default profile in Non-Production environments. - Demo - Profile: `demo` + - This is the profile used for the demo environment which is used for user acceptance testing and smoke testing. - Live - Profile: `live` - -To deploy a environment run `make deploy PROFILE=task` + - This is the profile used for the live environment which is used for production. ### Runbooks -List all the operational runbooks +The runbooks for this project can be found on the DI confluence. + ## Product @@ -534,17 +579,12 @@ List all the operational runbooks - Handy tips on how to get started as part of the DoS Integration team - Full Development Team (Private: Ask for invite) `dos-integration-devs` - For team conversations and team notifications - - Devs/Tests Only (Private: Ask for invite) `di-coders` - - For technical conversation without distracting the non technical team members - Swarming Channel (Public) `dos-integration-swarming` - For team meetings and swarming sessions. Generally used for huddles. - TO DO SLACK CHANNELS - - CI/CD and data pipelines, processes, e.g. `[service-name]-automation` - - Service status, e.g. `[service-name]-status` + - CI/CD and data pipelines, processes & Service status, `dos-integration-dev-status` and `dos-integration-live-status` - Email addresses in use, e.g. `[service.name]@nhs.net` -All of the above can be service, product, application or even team specific. - ### Documentation - Sprint board link diff --git a/application/.gitignore b/application/.gitignore index e6dff57b2..4ae335e85 100644 --- a/application/.gitignore +++ b/application/.gitignore @@ -1,5 +1,5 @@ # ===================================================================================================================== -# SEE: https://github.com/github/gitignore/blob/master/Python.gitignore +# SEE: https://github.com/github/gitignore/blob/main/Python.gitignore # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/application/authoriser/authoriser.py b/application/authoriser/authoriser.py deleted file mode 100644 index 3a046e570..000000000 --- a/application/authoriser/authoriser.py +++ /dev/null @@ -1,66 +0,0 @@ -from base64 import b64encode -from os import getenv -from typing import Any, Dict - -from json import loads -from aws_lambda_powertools.utilities.typing import LambdaContext -from boto3 import client - - -def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]: - """Entrypoint handler for the authoriser lambda - - Args: - event (Dict[str, Any]): Lambda function invocation event - context (LambdaContext): Lambda function context object - - Returns: - dict: Policy to allow connection to the API Gateway Mock - """ - try: - basic_auth = get_basic_auth() - assert event["authorizationToken"] == basic_auth, "Invalid credentials" - except Exception as e: - print(f"Authentication method failed with error: {e}") - return generate_policy(None, "Deny", event["methodArn"]) - return generate_policy("*", "Allow", event["methodArn"]) - - -def get_basic_auth() -> str: - """Get basic auth credentials from environment variables - - Returns: - str: Basic auth credentials - """ - sm = client("secretsmanager") - secret_string = sm.get_secret_value(SecretId=getenv("DOS_API_GATEWAY_CREDENTIALS_SECRET_NAME"))["SecretString"] - secret_dict = loads(secret_string) - username_key = getenv("DOS_API_GATEWAY_USERNAME_KEY") - password_key = getenv("DOS_API_GATEWAY_PASSWORD_KEY") - username = secret_dict[username_key] - password = secret_dict[password_key] - encoded_credentials = b64encode(bytes(f"{username}:{password}", encoding="utf-8")).decode("utf-8") - return f"Basic {encoded_credentials}" - - -def generate_policy(principal_id: Any, effect: str, method_arn: str) -> Dict[str, Any]: - """Generates policy to allow/deny connection to the DoS API Gateway Mock - - Args: - principal_id (str|None): Principal ID for Policy - effect (str): Effect for Policy (Allow or Deny) - method_arn (str): Method Arn for Resource - - Returns: - dict: Policy to allow/deny connection to the DoS API Gateway Mock - """ - auth_response = {} - auth_response["principalId"] = principal_id - policy_document = { - "Version": "2012-10-17", - "Statement": [ - {"Sid": "FirstStatement", "Action": "execute-api:Invoke", "Effect": effect, "Resource": method_arn} - ], - } - auth_response["policyDocument"] = policy_document - return auth_response diff --git a/application/authoriser/requirements.txt b/application/authoriser/requirements.txt deleted file mode 100644 index 56fd45918..000000000 --- a/application/authoriser/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -aws-lambda-powertools diff --git a/application/authoriser/tests/test_authoriser.py b/application/authoriser/tests/test_authoriser.py deleted file mode 100644 index 502a8fd9e..000000000 --- a/application/authoriser/tests/test_authoriser.py +++ /dev/null @@ -1,93 +0,0 @@ -from base64 import b64encode -from os import environ -from unittest.mock import patch - -from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext - -from ..authoriser import generate_policy, get_basic_auth, lambda_handler - -FILE_PATH = "application.authoriser.authoriser" - - -@patch(f"{FILE_PATH}.generate_policy") -@patch(f"{FILE_PATH}.get_basic_auth") -def test_lambda_handler(mock_get_basic_auth, mock_generate_policy): - # Arrange - context = LambdaContext() - event = { - "authorizationToken": "Basic test", - "methodArn": "arn:aws:execute-api:eu-west-1:123456789012:qwerty/dev/GET/hello", - } - mock_get_basic_auth.return_value = event["authorizationToken"] - test_policy = {"policy": "test"} - mock_generate_policy.return_value = test_policy - # Act - response = lambda_handler(event, context) - # Assert - assert response == test_policy - mock_generate_policy.assert_called_once_with("*", "Allow", event["methodArn"]) - - -@patch(f"{FILE_PATH}.generate_policy") -@patch(f"{FILE_PATH}.get_basic_auth") -def test_lambda_handler_incorrect_auth(mock_get_basic_auth, mock_generate_policy): - # Arrange - context = LambdaContext() - event = { - "authorizationToken": "Basic test", - "methodArn": "arn:aws:execute-api:eu-west-1:123456789012:qwerty/dev/GET/hello", - } - mock_get_basic_auth.return_value = "" - test_policy = {"policy": "test"} - mock_generate_policy.return_value = test_policy - # Act - response = lambda_handler(event, context) - # Assert - assert response == test_policy - mock_generate_policy.assert_called_once_with(None, "Deny", event["methodArn"]) - - -@patch(f"{FILE_PATH}.client") -def test_get_basic_auth(mock_sm_client): - # Arrange - username = "test_username" - password = "test_password" - secret_id = "test_secret_id" - environ["DOS_API_GATEWAY_CREDENTIALS_SECRET_NAME"] = secret_id - environ["DOS_API_GATEWAY_USERNAME_KEY"] = "test_username_key" - environ["DOS_API_GATEWAY_PASSWORD_KEY"] = "test_password_key" - secret_string = '{"test_username_key": "test_username", "test_password_key": "test_password"}' - mock_sm_client().get_secret_value.return_value = {"SecretString": secret_string} - expected_auth = "Basic " + b64encode(bytes(f"{username}:{password}", encoding="utf-8")).decode("utf-8") - # Act - response = get_basic_auth() - # Assert - assert response == expected_auth - mock_sm_client.assert_called_with("secretsmanager") - mock_sm_client().get_secret_value.assert_called_once_with(SecretId=secret_id) - # Clean up - del environ["DOS_API_GATEWAY_CREDENTIALS_SECRET_NAME"] - del environ["DOS_API_GATEWAY_USERNAME_KEY"] - del environ["DOS_API_GATEWAY_PASSWORD_KEY"] - - -def test_generate_policy(): - # Arrange - principal_id = "test_principal_id" - effect = "Allow" - resource = "test_resource" - # Act - response = generate_policy(principal_id, effect, resource) - # Assert - assert response["principalId"] == principal_id - assert response["policyDocument"] == { - "Statement": [ - { - "Action": "execute-api:Invoke", - "Effect": effect, - "Resource": resource, - "Sid": "FirstStatement", - }, - ], - "Version": "2012-10-17", - } diff --git a/application/authoriser/__init__.py b/application/change_event_dlq_handler/__init__.py similarity index 100% rename from application/authoriser/__init__.py rename to application/change_event_dlq_handler/__init__.py diff --git a/application/change_event_dlq_handler/change_event_dlq_handler.py b/application/change_event_dlq_handler/change_event_dlq_handler.py new file mode 100644 index 000000000..7cd938c36 --- /dev/null +++ b/application/change_event_dlq_handler/change_event_dlq_handler.py @@ -0,0 +1,69 @@ +from os import getenv + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.tracing import Tracer +from aws_lambda_powertools.utilities.data_classes import SQSEvent, event_source +from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext + +from common.dynamodb import add_change_event_to_dynamodb +from common.middlewares import redact_staff_key_from_event, unhandled_exception_logging +from common.utilities import extract_body, get_sequence_number, get_sqs_msg_attribute, handle_sqs_msg_attributes + +TTL = 157680000 # int((365*5)*24*60*60) . 5 years in seconds +tracer = Tracer() +logger = Logger() +CHANGE_EVENT_DLQ_HANDLER_EVENT = "CHANGE_EVENT_DLQ_HANDLER_RECEIVED_EVENT" + + +@redact_staff_key_from_event() +@unhandled_exception_logging() +@tracer.capture_lambda_handler() +@event_source(data_class=SQSEvent) +@logger.inject_lambda_context(clear_state=True) +def lambda_handler(event: SQSEvent, context: LambdaContext) -> None: # noqa: ARG001 + """Entrypoint handler for the change event dlq handler lambda. + + Messages are sent to the change event dlq handler lambda when a message + fails in either the change event queue or holding queue + + Args: + event (SQSEvent): Lambda function invocation event (list of 1 SQS Message) + context (LambdaContext): Lambda function context object + """ + record = next(event.records) + handle_sqs_msg_attributes(record.message_attributes) + body = extract_body(record.body) + if "dynamo_record_id" not in record.body: + # This is when a message comes from the change event queue + attributes = handle_sqs_msg_attributes(record.message_attributes) + correlation_id = get_sqs_msg_attribute(record.message_attributes, "correlation-id") + logger.set_correlation_id(correlation_id) + logger.append_keys(dynamo_record_id=get_sqs_msg_attribute(record.message_attributes, "dynamo_record_id")) + logger.append_keys(message_received=get_sqs_msg_attribute(record.message_attributes, "message_received")) + logger.append_keys(ods_code=get_sqs_msg_attribute(record.message_attributes, "ods_code")) + change_event = body + sequence_number = get_sequence_number(record) + else: + # This is when a message comes from the holding queue + attributes = handle_sqs_msg_attributes(record.message_attributes) + logger.info("Message received from holding queue", body=record.body) + change_event = body["change_event"] + correlation_id = body.get("correlation_id") + logger.set_correlation_id(correlation_id) + logger.append_keys(dynamo_record_id=body.get("dynamo_record_id")) + logger.append_keys(message_received=body.get("message_received")) + logger.append_keys(ods_code=change_event.get("ODSCode")) + sequence_number = body.get("sequence_number") + + error_msg = attributes["error_msg"] + logger.warning( + "Change Event Dead Letter Queue Handler received event", + report_key=CHANGE_EVENT_DLQ_HANDLER_EVENT, + error_msg=f"Message Abandoned: {error_msg}", + error_msg_http_code=attributes["error_msg_http_code"], + payload=change_event, + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="ChangeEventsDLQHandlerReceivedEvent", + ) + sqs_timestamp = int(record.attributes["SentTimestamp"]) + add_change_event_to_dynamodb(change_event, sequence_number, sqs_timestamp) diff --git a/application/change_event_dlq_handler/requirements.txt b/application/change_event_dlq_handler/requirements.txt new file mode 100644 index 000000000..e27f685d9 --- /dev/null +++ b/application/change_event_dlq_handler/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools[tracer] ~= 2.43.0 diff --git a/application/authoriser/tests/__init__.py b/application/change_event_dlq_handler/tests/__init__.py similarity index 100% rename from application/authoriser/tests/__init__.py rename to application/change_event_dlq_handler/tests/__init__.py diff --git a/application/change_event_dlq_handler/tests/test_change_event_dlq_handler.py b/application/change_event_dlq_handler/tests/test_change_event_dlq_handler.py new file mode 100644 index 000000000..500d3838f --- /dev/null +++ b/application/change_event_dlq_handler/tests/test_change_event_dlq_handler.py @@ -0,0 +1,166 @@ +from json import dumps +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from aws_lambda_powertools.utilities.typing import LambdaContext + +from application.change_event_dlq_handler.change_event_dlq_handler import lambda_handler +from application.conftest import PHARMACY_STANDARD_EVENT, PHARMACY_STANDARD_EVENT_STAFF + +FILE_PATH = "application.change_event_dlq_handler.change_event_dlq_handler" + +CHANGE_EVENT_FROM_CHANGE_EVENT_QUEUE = PHARMACY_STANDARD_EVENT.copy() +CHANGE_EVENT_FROM_HOLDING_QUEUE = { + "change_event": CHANGE_EVENT_FROM_CHANGE_EVENT_QUEUE, + "dynamo_record_id": "123456789", + "message_received": "2021-01-01T00:00:00.000000Z", + "sequence_number": "123456789", + "correlation_id": "123456789", +} + +STAFF_CHANGE_EVENT_FROM_CHANGE_EVENT_QUEUE = PHARMACY_STANDARD_EVENT_STAFF.copy() +STAFF_CHANGE_EVENT_FROM_HOLDING_QUEUE = { + "change_event": STAFF_CHANGE_EVENT_FROM_CHANGE_EVENT_QUEUE, + "dynamo_record_id": "123456789", + "message_received": "2021-01-01T00:00:00.000000Z", + "sequence_number": "123456789", + "correlation_id": "123456789", +} + + +@pytest.fixture() +def dead_letter_change_event_from_change_event_queue() -> None: + return { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": dumps(CHANGE_EVENT_FROM_CHANGE_EVENT_QUEUE), + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185", + }, + "messageAttributes": { + "correlation-id": { + "stringValue": "059f36b4-87a3-44ab-83d2-661975830a7d", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String", + }, + "sequence-number": { + "stringValue": "123456789", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String", + }, + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2", + }, + ], + } + + +@pytest.fixture() +def dead_letter_staff_change_event_from_change_event_queue() -> None: + return { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": dumps(STAFF_CHANGE_EVENT_FROM_CHANGE_EVENT_QUEUE), + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185", + }, + "messageAttributes": { + "correlation-id": { + "stringValue": "059f36b4-87a3-44ab-83d2-661975830a7d", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String", + }, + "sequence-number": { + "stringValue": "123456789", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String", + }, + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2", + }, + ], + } + + +@pytest.fixture() +def dead_letter_change_event_from_holding_queue() -> None: + return { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": dumps(CHANGE_EVENT_FROM_HOLDING_QUEUE), + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185", + }, + "messageAttributes": {}, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2", + }, + ], + } + + +@patch(f"{FILE_PATH}.extract_body") +@patch(f"{FILE_PATH}.add_change_event_to_dynamodb") +def test_lambda_handler_event_from_change_event_queue( + mock_add_change_event_to_dynamodb: MagicMock, + mock_extract_body: MagicMock, + dead_letter_staff_change_event_from_change_event_queue: dict[str, Any], + dead_letter_change_event_from_change_event_queue: dict[str, Any], + lambda_context: LambdaContext, +) -> None: + # Arrange + mock_extract_body.return_value = extracted_body = "Test message1." + # Act + assert "Staff" in dead_letter_staff_change_event_from_change_event_queue["Records"][0]["body"] + lambda_handler(dead_letter_staff_change_event_from_change_event_queue, lambda_context) + # Assert + mock_extract_body.assert_called_once_with(dead_letter_change_event_from_change_event_queue["Records"][0]["body"]) + expected_timestamp = int( + dead_letter_change_event_from_change_event_queue["Records"][0]["attributes"]["SentTimestamp"], + ) + mock_add_change_event_to_dynamodb.assert_called_once_with(extracted_body, 123456789, expected_timestamp) + + +@patch(f"{FILE_PATH}.add_change_event_to_dynamodb") +def test_lambda_handler_event_from_holding_queue( + mock_add_change_event_to_dynamodb: MagicMock, + dead_letter_change_event_from_holding_queue: dict[str, Any], + lambda_context: LambdaContext, +) -> None: + # Act + lambda_handler(dead_letter_change_event_from_holding_queue, lambda_context) + # Assert + expected_timestamp = int(dead_letter_change_event_from_holding_queue["Records"][0]["attributes"]["SentTimestamp"]) + mock_add_change_event_to_dynamodb.assert_called_once_with( + CHANGE_EVENT_FROM_CHANGE_EVENT_QUEUE, + CHANGE_EVENT_FROM_HOLDING_QUEUE["sequence_number"], + expected_timestamp, + ) diff --git a/application/common/appconfig.py b/application/common/appconfig.py deleted file mode 100644 index d1ef82fcd..000000000 --- a/application/common/appconfig.py +++ /dev/null @@ -1,39 +0,0 @@ -from os import getenv -from typing import Any, Dict - -from aws_lambda_powertools.utilities.feature_flags.appconfig import AppConfigStore -from aws_lambda_powertools.utilities.feature_flags.feature_flags import FeatureFlags - - -class AppConfig: - """Application configuration""" - - def __init__(self, name: str) -> None: - """Initialise the application configuration - - Args: - name (str): name of the application configuration profile - """ - self.name = name - environment: str = getenv("ENV") - self.app_config = AppConfigStore( - environment=environment, - application=f"uec-dos-int-{environment}-lambda-app-config", - name=name, - ) - - def get_raw_configuration(self) -> Dict[str, Any]: - """Get the raw configuration - - Returns: - dict: raw configuration - """ - return self.app_config.get_raw_configuration - - def get_feature_flags(self) -> FeatureFlags: - """Get the feature flags for the given name - - Returns: - FeatureFlags: feature flags class - """ - return FeatureFlags(store=self.app_config) diff --git a/application/common/change_event_exceptions.py b/application/common/change_event_exceptions.py deleted file mode 100644 index 2e3be022b..000000000 --- a/application/common/change_event_exceptions.py +++ /dev/null @@ -1,2 +0,0 @@ -class ValidationException(Exception): - pass diff --git a/application/common/commissioned_service_type.py b/application/common/commissioned_service_type.py new file mode 100644 index 000000000..74e843e60 --- /dev/null +++ b/application/common/commissioned_service_type.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass + +from .constants import ( + DOS_BLOOD_PRESSURE_SGSDID, + DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR, + DOS_BLOOD_PRESSURE_SYMPTOM_GROUP, + DOS_BLOOD_PRESSURE_TYPE_ID, + DOS_CONTRACEPTION_SGSDID, + DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR, + DOS_CONTRACEPTION_SYMPTOM_GROUP, + DOS_CONTRACEPTION_TYPE_ID, + DOS_PALLIATIVE_CARE_SGSDID, + DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + DOS_PALLIATIVE_CARE_TYPE_ID, + NHS_UK_BLOOD_PRESSURE_SERVICE_CODE, + NHS_UK_CONTRACEPTION_SERVICE_CODE, + NHS_UK_PALLIATIVE_CARE_SERVICE_CODE, +) + + +@dataclass +class CommissionedServiceType: + """Class to represent constant values of a Service Type.""" + + TYPE_NAME: str + NHS_UK_SERVICE_CODE: str + DOS_TYPE_ID: int + DOS_SYMPTOM_GROUP: int + DOS_SYMPTOM_DISCRIMINATOR: int + DOS_SG_SD_ID: str + + +BLOOD_PRESSURE = CommissionedServiceType( + TYPE_NAME="Blood Pressure", + NHS_UK_SERVICE_CODE=NHS_UK_BLOOD_PRESSURE_SERVICE_CODE, + DOS_TYPE_ID=DOS_BLOOD_PRESSURE_TYPE_ID, + DOS_SYMPTOM_GROUP=DOS_BLOOD_PRESSURE_SYMPTOM_GROUP, + DOS_SYMPTOM_DISCRIMINATOR=DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR, + DOS_SG_SD_ID=DOS_BLOOD_PRESSURE_SGSDID, +) + +CONTRACEPTION = CommissionedServiceType( + TYPE_NAME="Contraception", + NHS_UK_SERVICE_CODE=NHS_UK_CONTRACEPTION_SERVICE_CODE, + DOS_TYPE_ID=DOS_CONTRACEPTION_TYPE_ID, + DOS_SYMPTOM_GROUP=DOS_CONTRACEPTION_SYMPTOM_GROUP, + DOS_SYMPTOM_DISCRIMINATOR=DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR, + DOS_SG_SD_ID=DOS_CONTRACEPTION_SGSDID, +) + +PALLIATIVE_CARE = CommissionedServiceType( + TYPE_NAME="Palliative Care", + NHS_UK_SERVICE_CODE=NHS_UK_PALLIATIVE_CARE_SERVICE_CODE, + DOS_TYPE_ID=DOS_PALLIATIVE_CARE_TYPE_ID, + DOS_SYMPTOM_GROUP=DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + DOS_SYMPTOM_DISCRIMINATOR=DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + DOS_SG_SD_ID=DOS_PALLIATIVE_CARE_SGSDID, +) diff --git a/application/common/constants.py b/application/common/constants.py index 44256dfe6..5cd5d870d 100644 --- a/application/common/constants.py +++ b/application/common/constants.py @@ -1,37 +1,101 @@ -HIDDEN_OR_CLOSED_REPORT_ID = "HIDDEN_OR_CLOSED" -UNMATCHED_PHARMACY_REPORT_ID = "UNMATCHED_PHARMACY" -INVALID_POSTCODE_REPORT_ID = "INVALID_POSTCODE" -INVALID_OPEN_TIMES_REPORT_ID = "INVALID_OPEN_TIMES" -DLQ_HANDLER_REPORT_ID = "CR_DLQ_HANDLER_RECEIVED_EVENT" -FIFO_DLQ_HANDLER_REPORT_ID = "FIFO_DLQ_HANDLER_RECEIVED_EVENT" -UNMATCHED_SERVICE_TYPE_REPORT_ID = "UNMATCHED_SERVICE_TYPE" -GENERIC_BANK_HOLIDAY_REPORT_ID = "GENERIC_BANK_HOLIDAY" - -METRIC_REPORT_KEY_MAP = { - "InvalidPostcode": INVALID_POSTCODE_REPORT_ID, - "InvalidOpenTimes": INVALID_OPEN_TIMES_REPORT_ID, -} - -PHARMACY_SERVICE_KEY = "PHARMACY" -DENTIST_SERVICE_KEY = "DENTIST" PHARMACY_ORG_TYPE_ID = "PHA" -DENTIST_ORG_TYPE_ID = "Dentist" -SERVICE_TYPES_ALIAS_KEY = "SERVICE_TYPE_NAME" -ORGANISATION_SUB_TYPES_KEY = "ORGANISATION_SUB_TYPES" -VALID_SERVICE_TYPES_KEY = "VALID_SERVICE_TYPES" -ODSCODE_LENGTH_KEY = "ODSCODE_LENGTH" - -SERVICE_TYPES = { - PHARMACY_ORG_TYPE_ID: { - SERVICE_TYPES_ALIAS_KEY: PHARMACY_SERVICE_KEY, - ORGANISATION_SUB_TYPES_KEY: ["Community"], - VALID_SERVICE_TYPES_KEY: [13, 131, 132, 134, 137], - ODSCODE_LENGTH_KEY: 5, - }, - DENTIST_ORG_TYPE_ID: { - SERVICE_TYPES_ALIAS_KEY: DENTIST_SERVICE_KEY, - ORGANISATION_SUB_TYPES_KEY: ["TBA"], - VALID_SERVICE_TYPES_KEY: [12], - ODSCODE_LENGTH_KEY: 7, - }, -} + +CLOSED_AND_HIDDEN_STATUSES = ["HIDDEN", "CLOSED"] + +PHARMACY_SERVICE_TYPE_IDS = [13, 131, 132, 134, 137, 148, 149] +PHARMACY_ORGANISATION_SUB_TYPES = ["Community", "DistanceSelling"] +PHARMACY_ODSCODE_LENGTH = 5 +PHARMACY_SERVICE_TYPE_ID = 13 + +DOS_DEMOGRAPHICS_AREA_TYPE = "demographic" +DOS_CLINICAL_AREA_TYPE = "clinical" + +DOS_POSTCODE_CHANGE_KEY = "postalcode" +DOS_WEBSITE_CHANGE_KEY = "cmsurl" +DOS_ADDRESS_CHANGE_KEY = "postaladdress" +DOS_PUBLIC_PHONE_CHANGE_KEY = "cmstelephoneno" +DOS_EASTING_CHANGE_KEY = "cmseastings" +DOS_NORTHING_CHANGE_KEY = "cmsnorthings" +DOS_POSTAL_TOWN_CHANGE_KEY = "cmsorgtown" +DI_LATITUDE_CHANGE_KEY = "latitude" # DoS doesn't have a latitude change key so using one made up +DI_LONGITUDE_CHANGE_KEY = "longitude" # DoS doesn't have a longitude change key so using one made up +DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY = "cmsopentimespecified" +DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY = "cmsopentimemonday" +DOS_STANDARD_OPENING_TIMES_TUESDAY_CHANGE_KEY = "cmsopentimetuesday" +DOS_STANDARD_OPENING_TIMES_WEDNESDAY_CHANGE_KEY = "cmsopentimewednesday" +DOS_STANDARD_OPENING_TIMES_THURSDAY_CHANGE_KEY = "cmsopentimethursday" +DOS_STANDARD_OPENING_TIMES_FRIDAY_CHANGE_KEY = "cmsopentimefriday" +DOS_STANDARD_OPENING_TIMES_SATURDAY_CHANGE_KEY = "cmsopentimesaturday" +DOS_STANDARD_OPENING_TIMES_SUNDAY_CHANGE_KEY = "cmsopentimesunday" +DOS_SGSDID_CHANGE_KEY = "cmssgsdid" +DOS_STATUS_CHANGE_KEY = "cmsorgstatus" + +DOS_SERVICES_TABLE_CHANGE_TYPE_LIST = [ + DOS_ADDRESS_CHANGE_KEY, + DOS_EASTING_CHANGE_KEY, + DOS_NORTHING_CHANGE_KEY, + DOS_POSTAL_TOWN_CHANGE_KEY, + DOS_POSTCODE_CHANGE_KEY, + DOS_PUBLIC_PHONE_CHANGE_KEY, + DOS_WEBSITE_CHANGE_KEY, + DOS_STATUS_CHANGE_KEY, +] +DI_CHANGE_KEYS_LIST = [DI_LATITUDE_CHANGE_KEY, DI_LONGITUDE_CHANGE_KEY] + +DOS_STANDARD_OPENING_TIMES_CHANGE_KEY_LIST = [ + DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_TUESDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_WEDNESDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_THURSDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_FRIDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SATURDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SUNDAY_CHANGE_KEY, +] + +DI_CHANGE_ITEMS = [ + DOS_ADDRESS_CHANGE_KEY, + DOS_EASTING_CHANGE_KEY, + DOS_NORTHING_CHANGE_KEY, + DOS_POSTAL_TOWN_CHANGE_KEY, + DOS_POSTCODE_CHANGE_KEY, + DOS_PUBLIC_PHONE_CHANGE_KEY, + DOS_WEBSITE_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_TUESDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_WEDNESDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_THURSDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_FRIDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SATURDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SUNDAY_CHANGE_KEY, +] +DOS_INTEGRATION_USER_NAME = "DOS_INTEGRATION" + +DOS_SERVICE_HISTORY_ACTIVE_STATUS = "active" +DOS_SERVICE_HISTORY_CLOSED_STATUS = "closed" + +# Service Statuses +DOS_ACTIVE_STATUS_ID = 1 +DOS_CLOSED_STATUS_ID = 2 +DOS_COMMISSIONING_STATUS_ID = 3 + +# Palliative Care +MAIN_PHARMACY_ODSCODE_LENGTH = 5 +DOS_PALLIATIVE_CARE_TYPE_ID = 13 +NHS_UK_PALLIATIVE_CARE_SERVICE_CODE = "SRV0559" +DOS_PALLIATIVE_CARE_SYMPTOM_GROUP = 360 +DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR = 14167 +DOS_PALLIATIVE_CARE_SGSDID = f"{DOS_PALLIATIVE_CARE_SYMPTOM_GROUP}={DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR}" + +# Blood Pressure +DOS_BLOOD_PRESSURE_TYPE_ID = 148 +NHS_UK_BLOOD_PRESSURE_SERVICE_CODE = "SRV0560" +DOS_BLOOD_PRESSURE_SYMPTOM_GROUP = 360 +DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR = 14207 +DOS_BLOOD_PRESSURE_SGSDID = f"{DOS_BLOOD_PRESSURE_SYMPTOM_GROUP}={DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR}" + +# Contraception +DOS_CONTRACEPTION_TYPE_ID = 149 +NHS_UK_CONTRACEPTION_SERVICE_CODE = "SRV2000" +DOS_CONTRACEPTION_SYMPTOM_GROUP = 360 +DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR = 14210 +DOS_CONTRACEPTION_SGSDID = f"{DOS_CONTRACEPTION_SYMPTOM_GROUP}={DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR}" diff --git a/application/common/dos.py b/application/common/dos.py index b84a01ade..ed23e99d8 100644 --- a/application/common/dos.py +++ b/application/common/dos.py @@ -1,22 +1,31 @@ -from dataclasses import dataclass, field, fields +from collections.abc import Iterable +from dataclasses import dataclass, fields from itertools import groupby -from typing import List, Union -from datetime import datetime - -from aws_lambda_powertools import Logger - -from common.constants import DENTIST_ORG_TYPE_ID, PHARMACY_ORG_TYPE_ID -from .dos_db_connection import query_dos_db +from typing import Self + +from aws_lambda_powertools.logging import Logger +from psycopg import Connection + +from .constants import ( + DOS_ACTIVE_STATUS_ID, + DOS_CLOSED_STATUS_ID, + DOS_COMMISSIONING_STATUS_ID, + DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + PHARMACY_SERVICE_TYPE_IDS, +) +from .dos_db_connection import connect_to_db_reader, query_dos_db +from .dos_location import DoSLocation from .opening_times import OpenPeriod, SpecifiedOpeningTime, StandardOpeningTimes +from common.commissioned_service_type import BLOOD_PRESSURE, CONTRACEPTION, CommissionedServiceType -VALID_STATUS_ID = 1 logger = Logger(child=True) dos_location_cache = {} @dataclass class DoSService: - """Class to represent a DoS Service, field names are equal to equivalent db column names.""" + """Class to represent a DoS Service.""" id: int uid: int @@ -26,38 +35,40 @@ class DoSService: town: str postcode: str web: str - email: str - fax: str - nonpublicphone: str typeid: int - parentid: int - subregionid: int statusid: int - createdtime: datetime - modifiedtime: datetime + status_name: str publicphone: str publicname: str - servicename: str + service_type_name: str + easting: int + northing: int + latitude: float + longitude: float + region: str = "" @staticmethod - def field_names() -> List[str]: + def field_names() -> list[str]: + """Returns a list of field names for this class.""" return [f.name for f in fields(DoSService)] - def __init__(self, db_cursor_row: dict) -> None: - """Sets the attributes of this object to those found in the db row + def __init__(self: Self, db_cursor_row: dict) -> Self: + """Sets the attributes of this object to those found in the db row. + Args: - db_cursor_row (dict): row from db as key/val pairs + db_cursor_row (dict): row from db as key/val pairs. """ - self.data = db_cursor_row - for row_key, row_value in db_cursor_row.items(): setattr(self, row_key, row_value) - self._standard_opening_times = None - self._specified_opening_times = None + self.standard_opening_times = None + self.specified_opening_times = None + self.palliative_care = False + self.blood_pressure = False + self.contraception = False - def __repr__(self) -> str: - """Returns a string representation of this object""" + def __repr__(self: Self) -> str: + """Returns a string representation of this object.""" if self.publicname is not None: name = self.publicname elif self.name is not None: @@ -66,130 +77,157 @@ def __repr__(self) -> str: name = "NO-VALID-NAME" return ( - f"" ) - def normal_postcode(self) -> str: - return self.postcode.replace(" ", "").upper() - - def get_standard_opening_times(self) -> StandardOpeningTimes: - """Retrieves values from db on first call. Returns stored values on subsequent calls""" - if self._standard_opening_times is None: - self._standard_opening_times = get_standard_opening_times_from_db(self.id) - return self._standard_opening_times - - def get_specified_opening_times(self) -> List[SpecifiedOpeningTime]: - """Retrieves values from db on first call. Returns stored values on subsequent calls""" - if self._specified_opening_times is None: - self._specified_opening_times = get_specified_opening_times_from_db(self.id) - return self._specified_opening_times - - def any_generic_bankholiday_open_periods(self) -> bool: - return len(self.get_standard_opening_times().generic_bankholiday) > 0 + def __eq__(self: Self, other) -> bool: # noqa: ANN001 + """Checks DoS service equality using service id. + Args: + other (DoSService): DoS service to compare too -@dataclass(init=True, repr=True) -class DoSLocation: - id: int - postcode: str - easting: int - northing: int - latitude: float - longitude: float - postaltown: str = field(default=None) + Returns: + bool: True if equal, False if not + """ + return self.id == other.id - def normal_postcode(self) -> str: + def normal_postcode(self: Self) -> str: + """Returns the postcode with no spaces and in uppercase.""" return self.postcode.replace(" ", "").upper() - def is_valid(self) -> bool: - return None not in (self.easting, self.northing, self.latitude, self.longitude) + def any_generic_bankholiday_open_periods(self: Self) -> bool: + """Returns True if any of the opening times are generic bank holiday opening times.""" + return len(self.standard_opening_times.generic_bankholiday) > 0 + def get_region(self: Self) -> str: + """Returns the region of the service.""" + if not self.region: + self.region = get_region(self.id) + return self.region -def get_matching_dos_services(odscode: str, org_type_id: str) -> List[DoSService]: - """Retrieves DoS Services from DoS database + +def get_matching_dos_services(odscode: str) -> list[DoSService]: + """Retrieves DoS Services from DoS database. Args: odscode (str): ODScode to match on - org_type_id (str): OrganisationType to match on Returns: list[DoSService]: List of DoSService objects with matching first 5 digits of odscode, taken from DoS database """ - logger.info(f"Searching for '{org_type_id}' DoS services with ODSCode that matches '{odscode}'") - - if org_type_id == PHARMACY_ORG_TYPE_ID: - conditions = "odscode LIKE %(ODS)s" - named_args = {"ODS": f"{odscode[0:5]}%"} - elif org_type_id == DENTIST_ORG_TYPE_ID: - conditions = "odscode = %(ODS)s or odscode LIKE %(ODS7)s" - named_args = {"ODS": f"{odscode[0] + odscode[2:]}", "ODS7": f"{odscode[0:7]}%"} - else: - conditions = "odscode = %(ODS)s" - named_args = {"ODS": f"{odscode}%"} - + named_args = { + "ODS": f"{odscode[:5]}%", + "PHARMACY_SERVICE_TYPE_IDS": [13, 131, 132, 134, 137], + "ACTIVE_STATUS_ID": DOS_ACTIVE_STATUS_ID, + "PHARMACY_FIRST_SERVICE_TYPE_IDS": [148, 149], + "PHARMACY_FIRST_STATUSES": [DOS_ACTIVE_STATUS_ID, DOS_CLOSED_STATUS_ID, DOS_COMMISSIONING_STATUS_ID], + } sql_query = ( - "SELECT s.id, uid, s.name, odscode, address, town, postcode, web, email, fax, nonpublicphone, typeid," - " parentid, subregionid, statusid, createdtime, modifiedtime, publicphone, publicname, st.name servicename" - " FROM services s LEFT JOIN servicetypes st ON s.typeid = st.id" - f" WHERE {conditions}" + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid," + "statusid, ss.name status_name, publicphone, publicname, st.name service_type_name " + "FROM services s LEFT JOIN servicetypes st ON s.typeid = st.id " + "LEFT JOIN servicestatuses ss on s.statusid = ss.id " + "WHERE s.odscode LIKE %(ODS)s AND s.typeid = ANY(%(PHARMACY_SERVICE_TYPE_IDS)s) " + "AND s.statusid = %(ACTIVE_STATUS_ID)s OR s.odscode LIKE %(ODS)s " + "AND s.typeid = ANY(%(PHARMACY_FIRST_SERVICE_TYPE_IDS)s) AND s.statusid = ANY(%(PHARMACY_FIRST_STATUSES)s)" ) + with connect_to_db_reader() as connection: + cursor = query_dos_db(connection=connection, query=sql_query, query_vars=named_args) + # Create list of DoSService objects from returned rows + services = [DoSService(row) for row in cursor.fetchall()] + cursor.close() + # Connection closed by context manager + return services - c = query_dos_db(query=sql_query, vars=named_args) - # Create list of DoSService objects from returned rows - services = [DoSService(row) for row in c.fetchall()] - c.close() - return services +def get_dos_locations(postcode: str | None = None, try_cache: bool = True) -> list[DoSLocation]: + """Retrieves DoS Locations from DoS database. + + Args: + postcode (str, optional): Postcode to match on. Defaults to None. + try_cache (bool, optional): Whether to try and use the local cache. Defaults to True. + Returns: + list[DoSLocation]: List of DoSLocation objects with matching postcode, taken from DoS database + """ + logger.debug(f"Searching for DoS locations with postcode of '{postcode}'") + norm_pc = postcode.replace(" ", "").upper() + global dos_location_cache # noqa: PLW0602 + if try_cache and norm_pc in dos_location_cache: + logger.info(f"Postcode {norm_pc} location/s found in local cache.") + return dos_location_cache[norm_pc] + + # Search for any variation of whitespace in postcode + postcode_variations = [norm_pc] + [f"{norm_pc[:i]} {norm_pc[i:]}" for i in range(1, len(norm_pc))] + db_column_names = [f.name for f in fields(DoSLocation)] + sql_command = ( + f"SELECT {', '.join(db_column_names)} FROM locations WHERE postcode = ANY(%(pc_variations)s)" # noqa: S608 + # Safe as conditional is configurable but variables is inputted to psycopg as variables + ) + + with connect_to_db_reader() as connection: + cursor = query_dos_db( + connection=connection, + query=sql_command, + query_vars={"pc_variations": postcode_variations}, + ) + dos_locations = [DoSLocation(**row) for row in cursor.fetchall()] + cursor.close() + dos_location_cache[norm_pc] = dos_locations + logger.debug(f"Postcode location/s for {norm_pc} added to local cache.") + + return dos_locations + + +def get_valid_dos_location(postcode: str) -> DoSLocation | None: + """Gets the valid DoS location for the given postcode. + + Args: + postcode (str): The postcode to search for. + + Returns: + Optional[DoSLocation]: The valid DoS location for the given postcode or None if no valid location is found. + """ + dos_locations = [loc for loc in get_dos_locations(postcode) if loc.is_valid()] + return dos_locations[0] if dos_locations else None + + +def get_specified_opening_times_from_db(connection: Connection, service_id: int) -> list[SpecifiedOpeningTime]: + """Retrieves specified opening times from DoS database. -def get_specified_opening_times_from_db(service_id: int) -> List[SpecifiedOpeningTime]: - """Retrieves specified opening times from DoS database Args: - serviceid (int): serviceid to match on + connection (Connection): Connection to DoS database + service_id (int): serviceid to match on Returns: List[SpecifiedOpeningTime]: List of Specified Opening times with matching serviceid """ - - logger.info(f"Searching for specified opening times with serviceid that matches '{service_id}'") + logger.debug(f"Searching for specified opening times with serviceid that matches '{service_id}'") sql_query = ( "SELECT ssod.serviceid, ssod.date, ssot.starttime, ssot.endtime, ssot.isclosed " "FROM servicespecifiedopeningdates ssod " "INNER JOIN servicespecifiedopeningtimes ssot " "ON ssod.id = ssot.servicespecifiedopeningdateid " - "WHERE ssod.serviceid = %(service_id)s" + "WHERE ssod.serviceid = %(SERVICE_ID)s" ) - named_args = {"service_id": service_id} - c = query_dos_db(sql_query, named_args) - - """sort by date and then by starttime""" - sorted_list = sorted(c.fetchall(), key=lambda row: (row[1], row[2])) - - specified_opening_times = [] - for date, db_rows in groupby(sorted_list, lambda row: (row[1])): - is_open = True - open_periods = [] - for row in list(db_rows): - if row[4] is True: # row[4] is the 'is_closed' column - is_open = False - else: - open_periods.append(OpenPeriod(row[2], row[3])) - - specified_opening_times.append(SpecifiedOpeningTime(open_periods, date, is_open)) - - c.close() + named_args = {"SERVICE_ID": service_id} + cursor = query_dos_db(connection=connection, query=sql_query, query_vars=named_args) + specified_opening_times = db_rows_to_spec_open_times(cursor.fetchall()) + cursor.close() return specified_opening_times -def get_standard_opening_times_from_db(service_id: int) -> StandardOpeningTimes: - """Retrieves standard opening times from DoS database""" - - logger.info(f"Searching for standard opening times with serviceid that matches '{service_id}'") +def get_standard_opening_times_from_db(connection: Connection, service_id: int) -> StandardOpeningTimes: + """Retrieves standard opening times from DoS database. + If the service id does not even match any service this function will still return a blank StandardOpeningTime + with no opening periods. + """ + logger.debug(f"Searching for standard opening times with serviceid that matches '{service_id}'") sql_command = ( "SELECT sdo.serviceid, sdo.dayid, otd.name, sdot.starttime, sdot.endtime " "FROM servicedayopenings sdo " @@ -197,49 +235,152 @@ def get_standard_opening_times_from_db(service_id: int) -> StandardOpeningTimes: "ON sdo.id = sdot.servicedayopeningid " "LEFT JOIN openingtimedays otd " "ON sdo.dayid = otd.id " - "WHERE sdo.serviceid = %(service_id)s" + "WHERE sdo.serviceid = %(SERVICE_ID)s" ) - named_args = {"service_id": service_id} - c = query_dos_db(sql_command, named_args) + named_args = {"SERVICE_ID": service_id} + cursor = query_dos_db(connection=connection, query=sql_command, query_vars=named_args) + standard_opening_times = db_rows_to_std_open_times(cursor.fetchall()) + cursor.close() + return standard_opening_times + + +def db_rows_to_spec_open_times(db_rows: Iterable[dict]) -> list[SpecifiedOpeningTime]: + """Turns a set of dos database rows into a list of SpecifiedOpenTime objects. + + note: The rows must to be for the same service. + """ + specified_opening_times = [] + date_sorted_rows = sorted(db_rows, key=lambda row: (row["date"], row["starttime"])) + for date, rows in groupby(date_sorted_rows, lambda row: row["date"]): + is_open = True + open_periods = [] + for row in list(rows): + if row["isclosed"] is True: + is_open = False + else: + open_periods.append(OpenPeriod(row["starttime"], row["endtime"])) + specified_opening_times.append(SpecifiedOpeningTime(open_periods, date, is_open)) + + return specified_opening_times + +def db_rows_to_std_open_times(db_rows: Iterable[dict]) -> StandardOpeningTimes: + """Turns a set of dos database rows into a StandardOpeningTime object. + + note: The rows must be for the same service. + """ standard_opening_times = StandardOpeningTimes() - for row in c.fetchall(): - weekday = row[2].lower() - start = row[3] - end = row[4] + for row in db_rows: + weekday = row["name"].lower() + start = row["starttime"] + end = row["endtime"] open_period = OpenPeriod(start, end) standard_opening_times.add_open_period(open_period, weekday) - - c.close() return standard_opening_times -def get_dos_locations(postcode: str) -> List[DoSLocation]: - logger.info(f"Searching for DoS locations with postcode of '{postcode}'") +def has_palliative_care(service: DoSService, connection: Connection) -> bool: + """Checks if a service has palliative care. - normalised_pc = postcode.replace(" ", "").upper() - global dos_location_cache - if normalised_pc in dos_location_cache: - logger.info(f"Postcode {normalised_pc} location/s found in local cache.") - return dos_location_cache[normalised_pc] + Args: + service: The service to check + connection: The database connection to use - # Regex matches any combination of whitespace in postcode - pc_regex = " *".join(normalised_pc) - db_column_names = [f.name for f in fields(DoSLocation)] - sql_command = f"SELECT {', '.join(db_column_names)} FROM locations WHERE postcode ~* %(pc_regex)s" - named_args = {"pc_regex": pc_regex} - c = query_dos_db(sql_command, named_args) + Returns: + True if the service has palliative care, False otherwise + """ + if service.typeid in PHARMACY_SERVICE_TYPE_IDS: + sql_command = """SELECT sgsds.id as z_code from servicesgsds sgsds + WHERE sgsds.serviceid = %(SERVICE_ID)s + AND sgsds.sgid = %(PALLIATIVE_CARE_SYMPTOM_GROUP)s + AND sgsds.sdid = %(PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR)s + """ + named_args = { + "SERVICE_ID": service.id, + "PALLIATIVE_CARE_SYMPTOM_GROUP": DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + "PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR": DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + } + cursor = query_dos_db(connection=connection, query=sql_command, query_vars=named_args) + cursor.fetchall() + logger.debug("Checked if service has palliative care", has_palliative_care=cursor.rowcount != 0) + return cursor.rowcount != 0 + return False + + +def has_blood_pressure(service: DoSService) -> bool: + """Checks if a service has blood pressure. - dos_locations = [DoSLocation(**row) for row in c.fetchall()] - dos_location_cache[normalised_pc] = dos_locations - logger.debug(f"Postcode location/s for {normalised_pc} added to local cache.") + Args: + service: The service to check - return dos_locations + Returns: + True if the service has blood pressure, False otherwise + """ + return has_service(service, BLOOD_PRESSURE) -def get_valid_dos_postcode(postcode: str) -> Union[str, None]: - """Finds the valid DoS formatted version of the given postcode. Or None if not a valid DoS postcode""" - dos_locations = [loc for loc in get_dos_locations(postcode) if loc.is_valid()] - if len(dos_locations) == 0: - return None - return dos_locations[0].postcode +def has_contraception(service: DoSService) -> bool: + """Checks if a service has contraception. + + Args: + service: The service to check + + Returns: + True if the service has contraception, False otherwise + """ + return has_service(service, CONTRACEPTION) + + +def has_service(service: DoSService, service_type: CommissionedServiceType) -> bool: + """Checks if a service has a given service type. + + Args: + service: The service to check + service_type: The service type to check for + + Returns: + True if the service has the given service type, False otherwise + """ + if service.typeid == service_type.DOS_TYPE_ID: + status = service.statusid == DOS_ACTIVE_STATUS_ID + logger.debug("Checked if service has {service_type.TYPE_NAME.lower()}", service_status=status) + return status + return False + + +def get_region(dos_service_id: str) -> str: + """Returns the region of the service. + + Args: + dos_service_id: The id of the service + + Returns: + The region of the service + """ + with connect_to_db_reader() as connection: + logger.debug("Getting region for service") + sql_command = """WITH +RECURSIVE servicetree as +(SELECT ser.parentid, ser.id, ser.uid, ser.name, 1 AS lvl +FROM services ser where ser.id = %(SERVICE_ID)s +UNION ALL +SELECT ser.parentid, st.id, ser.uid, ser.name, lvl+1 AS lvl +FROM services ser +INNER JOIN servicetree st ON ser.id = st.parentid), +serviceregion as +(SELECT st.*, ROW_NUMBER() OVER (PARTITION BY st.id ORDER BY st.lvl desc) rn +FROM servicetree st) +SELECT sr.name region +FROM serviceregion sr +INNER JOIN services ser ON sr.id = ser.id +LEFT OUTER JOIN services par ON ser.parentid = par.id +WHERE sr.rn=1 +ORDER BY ser.name + """ + named_args = {"SERVICE_ID": dos_service_id} + cursor = query_dos_db(connection=connection, query=sql_command, query_vars=named_args) + region_response = cursor.fetchone() + region_name = region_response["region"] if region_response else "Region not found" + logger.debug("Got region for service", region_name=region_name) + cursor.close() + return region_name diff --git a/application/common/dos_db_connection.py b/application/common/dos_db_connection.py index 06c24ee0f..4d25485c6 100644 --- a/application/common/dos_db_connection.py +++ b/application/common/dos_db_connection.py @@ -1,78 +1,126 @@ -from os import environ, getenv -from typing import Union +from collections.abc import Generator +from contextlib import contextmanager +from os import environ +from time import time_ns +from typing import Any, LiteralString + +from aws_lambda_powertools.logging import Logger +from psycopg import Connection, Cursor, connect +from psycopg.rows import DictRow, dict_row -import psycopg2 -from aws_lambda_powertools import Logger from common.secretsmanager import get_secret -from psycopg2.extensions import connection -from psycopg2.extras import DictCursor logger = Logger(child=True) db_connection = None -def _connect_dos_db() -> connection: - """Creates a new connection to the DoS DB and returns the connection object +@contextmanager +def connect_to_db_reader() -> Generator[Connection, None, None]: + """Creates a new connection to the DoS DB Reader. + + Yields: + Generator[connection, None, None]: Connection to the database + """ + # Use AWS secret values, or failing that check env for DB password + if "DB_READER_SECRET_NAME" in environ and "DB_READER_SECRET_KEY" in environ: + db_secret = get_secret(environ["DB_READER_SECRET_NAME"]) + db_password = db_secret[environ["DB_READER_SECRET_KEY"]] + else: + db_password = environ["DB_SECRET"] + + # Before the context manager is entered, the connection is created + db_connection = connection_to_db( + server=environ["DB_READER_SERVER"], + port=environ["DB_PORT"], + db_name=environ["DB_NAME"], + db_schema=environ["DB_SCHEMA"], + db_user=environ["DB_READ_ONLY_USER_NAME"], + db_password=db_password, + ) + # Yield the connection object to the context manager + yield db_connection + # After the context manager is exited, the connection is closed + db_connection.close() + + +@contextmanager +def connect_to_db_writer() -> Generator[Connection[DictRow], None, None]: + """Creates a new connection to the DoS DB Writer. - warning: Do not use. Should only be used by query_dos_db() func + Yields: + Generator[connection, None, None]: Connection to the database """ - db_secret = get_secret(environ["DB_SECRET_NAME"]) - server = environ["DB_SERVER"] - port = environ["DB_PORT"] - db_name = environ["DB_NAME"] - db_schema = environ["DB_SCHEMA"] - db_user = environ["DB_USER_NAME"] - db_password = db_secret[environ["DB_SECRET_KEY"]] - trace_id = getenv("_X_AMZN_TRACE_ID", default="") - - logger.debug(f"Attempting connection to database '{server}'") - logger.debug(f"host={server}, port={port}, dbname={db_name}, schema={db_schema} user={db_user}") - - db = psycopg2.connect( + # Before the context manager is entered, the connection is created + db_secret = get_secret(environ["DB_WRITER_SECRET_NAME"]) + db_connection = connection_to_db( + server=environ["DB_WRITER_SERVER"], + port=environ["DB_PORT"], + db_name=environ["DB_NAME"], + db_schema=environ["DB_SCHEMA"], + db_user=environ["DB_READ_AND_WRITE_USER_NAME"], + db_password=db_secret[environ["DB_WRITER_SECRET_KEY"]], + ) + # Yield the connection object to the context manager + yield db_connection + # After the context manager is exited, the connection is closed + db_connection.close() + + +def connection_to_db( + server: str, + port: str, + db_name: str, + db_schema: str, + db_user: str, + db_password: str, +) -> Connection: + """Creates a new connection to a database. + + Args: + server (str): Database server to connect to + port (str): Database port to connect to + db_name (str): Database name to connect to + db_schema (str): Database schema to connect to + db_user (str): Database user to connect as + db_password (str): Database password for the user + + Returns: + connection: Connection to the database + """ + logger.debug( + f"Attempting connection to database: '{server}', host={server}, port={port}, " + f"dbname={db_name}, schema={db_schema}, user={db_user}", + ) + return connect( host=server, port=port, dbname=db_name, user=db_user, password=db_password, - connect_timeout=30, + connect_timeout=2, options=f"-c search_path=dbo,{db_schema}", - application_name=f"DI-Application tid={trace_id}", + application_name="DOS INTEGRATION ", ) - return db +def query_dos_db( + connection: Connection, + query: LiteralString, + query_vars: dict[str, Any] | None = None, +) -> Cursor[DictRow]: + """Queries the database given in the connection object. -def disconnect_dos_db() -> None: - """Closes the DoS database connection if it exists and is open""" - global db_connection - if db_connection is not None: - try: - db_connection.close() - logger.info("The DoS database connection was closed.") - except Exception as e: - logger.exception(f"There was an exception while trying to close DoS database connection: {e}") + Args: + connection (Connection): Connection to the database + query (str): Query to execute + query_vars (Optional[Dict[str, Any]], optional): Variables to use in the query. Defaults to None. - -def query_dos_db(query: str, vars: Union[tuple, dict, None] = None) -> DictCursor: - """Queries the dos database with given sql command and returns the resulting cursor object""" - - # Check if new connection needed. - global db_connection - if db_connection is None or db_connection.closed != 0: - db_connection = _connect_dos_db() - else: - logger.info("Using existing open database connection.") - - c = db_connection.cursor(cursor_factory=DictCursor) - logger.info(f"Running SQL command: {c.mogrify(query, vars)}") - c.execute(query, vars) - return c - - -def _set_db_connection(value): - global db_connection - db_connection = value - - -def _get_db_connection(): - return db_connection + Returns: + DictRow: Cursor to the query results + """ + cursor = connection.cursor(row_factory=dict_row) + logger.debug("Query to execute", query=query, vars=query_vars) + time_start = time_ns() // 1000000 + cursor.execute(query=query, params=query_vars) + logger.debug(f"DoS DB query completed in {(time_ns() // 1000000) - time_start}ms") + return cursor diff --git a/application/common/dos_location.py b/application/common/dos_location.py new file mode 100644 index 000000000..ea4c72f97 --- /dev/null +++ b/application/common/dos_location.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Self + + +@dataclass(init=True, repr=True) +class DoSLocation: + """A Class to represent a location in the UK store within the DoS Database locations table.""" + + id: int + postcode: str + easting: float + northing: float + postaltown: str + latitude: float + longitude: float + + def normal_postcode(self: Self) -> str: + """Returns the postcode in a normalised format.""" + return self.postcode.replace(" ", "").upper() + + def is_valid(self: Self) -> bool: + """Returns True if the location is valid.""" + return None not in (self.easting, self.northing, self.latitude, self.longitude) diff --git a/application/common/dynamodb.py b/application/common/dynamodb.py index b69b643f6..120fb17aa 100644 --- a/application/common/dynamodb.py +++ b/application/common/dynamodb.py @@ -1,83 +1,36 @@ -import boto3 -from json import dumps, loads import hashlib from decimal import Decimal -from typing import Any, Dict, Union -from boto3.dynamodb.types import TypeSerializer -from time import time +from json import dumps, loads from os import environ +from time import time +from typing import Any + from aws_lambda_powertools.logging.logger import Logger +from boto3 import client +from boto3.dynamodb.types import TypeSerializer + +from common.errors import DynamoDBError -TTL = 157680000 # int((365*5)*24*60*60) . 5 years in seconds +TTL = 157680000 # int((365*5)*24*60*60) 5 years in seconds logger = Logger(child=True) +dynamodb = client("dynamodb", region_name=environ["AWS_REGION"]) -def dict_hash(change_event: Dict[str, Any], sequence_number: str) -> str: +def dict_hash(change_event: dict[str, Any], sequence_number: str) -> str: """MD5 hash of a dictionary.""" - change_event_hash = hashlib.md5() + change_event_hash = hashlib.new("md5", usedforsecurity=False) encoded = dumps([change_event, sequence_number], sort_keys=True).encode() change_event_hash.update(encoded) return change_event_hash.hexdigest() -def put_circuit_is_open(circuit: str, is_open: bool) -> None: - """Set the circuit open status for a given circuit - Args: - circuit (str): Name of the circuit - is_open (bool): boolean as to whether the circuit is open (broken) or closed +def add_change_event_to_dynamodb(change_event: dict[str, Any], sequence_number: int, event_received_time: int) -> str: + """Add change event to dynamodb but store the message and use the event for details. - Returns: - None - """ - dynamo_record = { - "Id": circuit, - "ODSCode": "CIRCUIT", - "IsOpen": is_open, - } - try: - dynamodb = boto3.client("dynamodb", region_name=environ["AWS_REGION"]) - serializer = TypeSerializer() - put_item = {k: serializer.serialize(v) for k, v in dynamo_record.items()} - response = dynamodb.put_item(TableName=environ["CHANGE_EVENTS_TABLE_NAME"], Item=put_item) - logger.info("Put circuit status", extra={"response": response, "item": put_item}) - except Exception as err: - raise Exception(f"Unable to set circuit '{circuit}' to open.") from err - - -def get_circuit_is_open(circuit: str) -> Union[bool, None]: - """Gets the open status of a given circuit - Args: - circuit (str): Name of the circuit - Returns: - Union[bool, None]: returns the status or None if the circuit does not exist - """ - try: - dynamodb = boto3.client("dynamodb", region_name=environ["AWS_REGION"]) - respone = dynamodb.get_item( - TableName=environ["CHANGE_EVENTS_TABLE_NAME"], - Key={ - "Id": { - "S": circuit, - }, - "ODSCode": { - "S": "CIRCUIT", - }, - }, - ) - item = respone.get("Item") - if item is None: - return None - else: - return int(item["IsOpen"]["BOOL"]) - except Exception as err: - raise Exception(f"Unable to get circuit status for '{circuit}'.") from err - - -def add_change_request_to_dynamodb(change_event: Dict[str, Any], sequence_number: int, event_received_time: int) -> str: - """Add change request to dynamodb but store the message and use the event for details Args: change_event (Dict[str, Any]): sequence id for given ODSCode - event_received_time (str): received timestamp from SQSEvent + sequence_number (int): sequence id for given ODSCode + event_received_time (str): received timestamp from SQSEvent. Returns: dict: returns response from dynamodb @@ -92,39 +45,36 @@ def add_change_request_to_dynamodb(change_event: Dict[str, Any], sequence_number "Event": loads(dumps(change_event), parse_float=Decimal), } try: - dynamodb = boto3.client("dynamodb", region_name=environ["AWS_REGION"]) serializer = TypeSerializer() put_item = {k: serializer.serialize(v) for k, v in dynamo_record.items()} response = dynamodb.put_item(TableName=environ["CHANGE_EVENTS_TABLE_NAME"], Item=put_item) - logger.info("Added record to dynamodb", extra={"response": response, "item": put_item}) + logger.info("Added record to dynamodb", response=response, item=put_item) except Exception as err: - raise Exception(f"Unable to add change request (seq no: {sequence_number}) into dynamodb") from err + msg = f"Unable to add change event (seq no: {sequence_number}) into dynamodb" + raise DynamoDBError(msg) from err return record_id def get_latest_sequence_id_for_a_given_odscode_from_dynamodb(odscode: str) -> int: - """Get latest sequence id for a given odscode from dynamodb + """Get latest sequence id for a given odscode from dynamodb. + Args: odscode (str): odscode for the change event + Returns: - int: Sequence number of the message or None if not present + int: Sequence number of the message or None if not present. """ - try: - dynamodb = boto3.client("dynamodb", region_name=environ["AWS_REGION"]) - resp = dynamodb.query( - TableName=environ["CHANGE_EVENTS_TABLE_NAME"], - IndexName="gsi_ods_sequence", - KeyConditionExpression="ODSCode = :odscode", - ExpressionAttributeValues={ - ":odscode": {"S": odscode}, - }, - Limit=1, - ScanIndexForward=False, - ProjectionExpression="ODSCode,SequenceNumber", - ) - sequence_number = 0 - if resp.get("Count") > 0: - sequence_number = int(resp.get("Items")[0]["SequenceNumber"]["N"]) - except Exception as err: - raise Exception(f"Unable to get sequence id from dynamodb for a given ODSCode '{odscode}'.") from err + resp = dynamodb.query( + TableName=environ["CHANGE_EVENTS_TABLE_NAME"], + IndexName="gsi_ods_sequence", + KeyConditionExpression="ODSCode = :odscode", + ExpressionAttributeValues={":odscode": {"S": odscode}}, + Limit=1, + ScanIndexForward=False, + ProjectionExpression="ODSCode,SequenceNumber", + ) + sequence_number = 0 + if resp.get("Count") > 0: + sequence_number = int(resp.get("Items")[0]["SequenceNumber"]["N"]) + logger.debug(f"Sequence number for osdscode '{odscode}'= {sequence_number}") return sequence_number diff --git a/application/common/errors.py b/application/common/errors.py new file mode 100644 index 000000000..3aab94552 --- /dev/null +++ b/application/common/errors.py @@ -0,0 +1,6 @@ +class ValidationError(Exception): + """Exception raised for errors in the input.""" + + +class DynamoDBError(Exception): + """Exception raised for all DynamoDB errors.""" diff --git a/application/common/middlewares.py b/application/common/middlewares.py index 100c512c1..d98f11ff2 100644 --- a/application/common/middlewares.py +++ b/application/common/middlewares.py @@ -1,37 +1,79 @@ -from aws_lambda_powertools import Logger +from typing import Any + +from aws_lambda_powertools.logging import Logger from aws_lambda_powertools.middleware_factory import lambda_handler_decorator -from aws_lambda_powertools.utilities.data_classes import SQSEvent from aws_lambda_powertools.utilities.typing import LambdaContext from botocore.exceptions import ClientError -from common.change_event_exceptions import ValidationException - +from common.errors import ValidationError +from common.utilities import extract_body, json_str_body logger = Logger(child=True) @lambda_handler_decorator(trace_execution=True) -def unhandled_exception_logging(handler, event, context: LambdaContext): +def redact_staff_key_from_event(handler, event, context: LambdaContext) -> Any: # noqa: ANN001, ANN401 + """Lambda middleware to remove the 'Staff' key from the Change Event payload. + + Args: + handler: Lambda handler function + event: Lambda event + context: Lambda context object + + Returns: + Any: Lambda handler response + """ + logger.info("Checking if 'Staff' key needs removing from Change Event payload") + if "Records" in event and list(event["Records"]): + for record in event["Records"]: + change_event = extract_body(record["body"]) + if change_event.pop("Staff", None) is not None: + record["body"] = json_str_body(change_event) + logger.info("Redacted 'Staff' key from Change Event payload") + return handler(event, context) + + +@lambda_handler_decorator(trace_execution=True) +def unhandled_exception_logging(handler, event, context: LambdaContext) -> Any: # noqa: ANN001, ANN401 + """Lambda middleware to log unhandled exceptions. + + Args: + handler: Lambda handler function + event: Lambda event + context: Lambda context object + + Returns: + Any: Lambda handler response + """ try: - response = handler(event, context) - return response - except ValidationException as err: - logger.exception(f"Validation Error - {err}", extra={"error": err, "event": event}) - return + return handler(event, context) + except ValidationError as error: + logger.exception(f"Validation Error - {error}", event=event) # noqa: TRY401 + return None except ClientError as err: error_code = err.response["Error"]["Code"] error_msg = err.response["Error"]["Message"] - logger.exception(f"Boto3 Client Error - '{error_code}': {error_msg}", extra={"error": err, "event": event}) - raise err - except BaseException as err: - logger.exception(f"Something went wrong - {err}", extra={"error": err, "event": event}) - raise err + logger.exception(f"Boto3 Client Error - '{error_code}': {error_msg}", error=err, event=event) + raise + except BaseException: + logger.exception("Error Occurred", event=event) + raise @lambda_handler_decorator(trace_execution=True) -def set_correlation_id(handler, event: SQSEvent, context: LambdaContext): - """Set correlation id from SQS event""" - record = next(event.records) - logger.set_correlation_id(record.message_attributes["correlation-id"]["stringValue"]) - response = handler(event, context) - return response +def unhandled_exception_logging_hidden_event(handler, event, context: LambdaContext) -> Any: # noqa: ANN001, ANN401 + """Lambda middleware to log unhandled exceptions but hide the event. + + Args: + handler: Lambda handler function + event: Lambda event + context: Lambda context object + + Returns: + Any: Lambda handler response + """ + try: + return handler(event, context) + except BaseException: + logger.exception("Something went wrong but the event is hidden") + raise diff --git a/application/event_processor/nhs.py b/application/common/nhs.py similarity index 53% rename from application/event_processor/nhs.py rename to application/common/nhs.py index 46ffa6df7..461b4e097 100644 --- a/application/event_processor/nhs.py +++ b/application/common/nhs.py @@ -1,9 +1,18 @@ from dataclasses import dataclass from datetime import datetime from itertools import groupby -from typing import List, Union - -from aws_lambda_powertools import Logger +from typing import Self + +from aws_lambda_powertools.logging import Logger + +from common.constants import ( + CLOSED_AND_HIDDEN_STATUSES, + NHS_UK_BLOOD_PRESSURE_SERVICE_CODE, + NHS_UK_CONTRACEPTION_SERVICE_CODE, + NHS_UK_PALLIATIVE_CARE_SERVICE_CODE, + PHARMACY_SERVICE_TYPE_IDS, +) +from common.dos import DoSService from common.opening_times import WEEKDAYS, OpenPeriod, SpecifiedOpeningTime, StandardOpeningTimes logger = Logger(child=True) @@ -11,7 +20,7 @@ @dataclass class NHSEntity: - """This is an object to store an NHS Entity data + """This is an object to store an NHS Entity data. Some fields are pulled straight from the payload while others are processed first. So attribute names differ from payload format for consistency within object. @@ -24,15 +33,18 @@ class NHSEntity: org_type: str org_sub_type: str org_status: str - address_lines: List[str] + address_lines: list[str] postcode: str website: str phone: str - standard_opening_times: Union[StandardOpeningTimes, None] - specified_opening_times: Union[List[SpecifiedOpeningTime], None] - CLOSED_AND_HIDDEN_STATUSES = ["HIDDEN", "CLOSED"] - - def __init__(self, entity_data: dict): + standard_opening_times: StandardOpeningTimes | None + specified_opening_times: list[SpecifiedOpeningTime] | None + palliative_care: bool + blood_pressure: bool + contraception: bool + + def __init__(self: Self, entity_data: dict) -> None: + """Initialise the object with the entity data.""" self.entity_data = entity_data self.odscode = entity_data.get("ODSCode") @@ -41,42 +53,78 @@ def __init__(self, entity_data: dict): self.org_type = entity_data.get("OrganisationType") self.org_sub_type = entity_data.get("OrganisationSubType") self.org_status = entity_data.get("OrganisationStatus") - self.odscode = entity_data.get("ODSCode") self.postcode = entity_data.get("Postcode") self.parent_org_name = entity_data.get("ParentOrganisation", {}).get("OrganisationName") - self.city = entity_data.get("City") - self.county = entity_data.get("County") self.address_lines = [ line for line in [entity_data.get(x) for x in [f"Address{i}" for i in range(1, 5)] + ["City", "County"]] - if isinstance(line, str) and line.strip() != "" + if isinstance(line, str) and line.strip() ] self.standard_opening_times = self._get_standard_opening_times() self.specified_opening_times = self._get_specified_opening_times() self.phone = self.extract_contact("Telephone") self.website = self.extract_contact("Website") + self.palliative_care = self.check_for_uec_service(NHS_UK_PALLIATIVE_CARE_SERVICE_CODE) + self.blood_pressure = self.check_for_service(NHS_UK_BLOOD_PRESSURE_SERVICE_CODE) + self.contraception = self.check_for_service(NHS_UK_CONTRACEPTION_SERVICE_CODE) - def __repr__(self) -> str: + logger.append_keys(nhsuk_organisation_typeid=self.org_type_id, nhsuk_organisation_name=self.org_name) + + def __repr__(self: Self) -> str: + """Returns a string representation of the object.""" return f"" - def normal_postcode(self): + def normal_postcode(self: Self) -> str: + """Returns the postcode in a normalised format.""" return self.postcode.replace(" ", "").upper() - def extract_contact(self, contact_type: str) -> Union[str, None]: - """Returns the nested contact value within the input payload""" - for item in self.entity_data.get("Contacts", []): - if ( - item.get("ContactMethodType", "").upper() == contact_type.upper() - and item.get("ContactType", "").upper() == "PRIMARY" - and item.get("ContactAvailabilityType", "").upper() == "OFFICE HOURS" - ): + def extract_contact(self: Self, contact_type: str) -> str | None: + """Returns the nested contact value within the input payload.""" + return next( + ( + item.get("ContactValue") + for item in self.entity_data.get("Contacts", []) + if ( + item.get("ContactMethodType", "").upper() == contact_type.upper() + and item.get("ContactType", "").upper() == "PRIMARY" + and item.get("ContactAvailabilityType", "").upper() == "OFFICE HOURS" + ) + ), + None, + ) + + def check_for_uec_service(self: Self, service_code: str) -> bool | None: + """Checks if the UEC service exists in the payload. + + Args: + service_code (str): NHS UK Service Code of the UEC service to extract if exists + + Returns: + Union[bool, None]: True if the service exists, False otherwise + """ + return self._extract_service_from_list("UecServices", service_code) + + def check_for_service(self: Self, service_code: str) -> bool | None: + """Checks if the service exists in the payload. - return item.get("ContactValue") + Args: + service_code (str): NHS UK Service Code of the service to extract if exists + + Returns: + Union[bool, None]: True if the service exists, False otherwise + """ + return self._extract_service_from_list("Services", service_code) + + def _extract_service_from_list(self: Self, list_name: str, service_code: str) -> bool | None: + if isinstance(self.entity_data.get(list_name, []), list): + return any(item.get("ServiceCode") == service_code for item in self.entity_data.get(list_name, [])) return None - def _get_standard_opening_times(self) -> StandardOpeningTimes: - """Filters the raw opening times data for standard weekly opening + def _get_standard_opening_times(self: Self) -> StandardOpeningTimes: + """Get the standard opening times. + + Filters the raw opening times data for standard weekly opening times and returns it in a StandardOpeningTimes object. Args: @@ -98,8 +146,8 @@ def _get_standard_opening_times(self) -> StandardOpeningTimes: return std_opening_times - def _get_specified_opening_times(self) -> List[SpecifiedOpeningTime]: - """Get all the Specified Opening Times + def _get_specified_opening_times(self: Self) -> list[SpecifiedOpeningTime]: + """Get all the Specified Opening Times. Args: opening_time_type (str): OpeningTimeType to filter the data, General for pharmacy @@ -127,17 +175,16 @@ def _get_specified_opening_times(self) -> List[SpecifiedOpeningTime]: return specified_opening_times - def is_status_hidden_or_closed(self) -> bool: - """Check if the status is hidden or closed. If so, return True + def is_status_hidden_or_closed(self: Self) -> bool: + """Check if the status is hidden or closed. If so, return True. Returns: bool: True if status is hidden or closed, False otherwise """ - return self.org_status.upper() in self.CLOSED_AND_HIDDEN_STATUSES - - def all_times_valid(self) -> bool: - """Does checks on all opening times for correct format, business rules, overlaps""" + return self.org_status.upper() in CLOSED_AND_HIDDEN_STATUSES + def all_times_valid(self: Self) -> bool: + """Does checks on all opening times for correct format, business rules, overlaps.""" # Check format matches either spec or std format for item in self.entity_data.get("OpeningTimes", []): if not (is_std_opening_json(item) or is_spec_opening_json(item)): @@ -146,17 +193,37 @@ def all_times_valid(self) -> bool: # Check validity of both types of open times return self.standard_opening_times.is_valid() and SpecifiedOpeningTime.valid_list(self.specified_opening_times) + def is_matching_dos_service(self: Self, dos_service: DoSService) -> bool: + """Check if the entity matches the DoS service. -def is_std_opening_json(item: dict) -> bool: - """Checks EXACT match to definition of General/Standard opening time for NHS Open time payload object""" + Args: + dos_service (DoSService): DoS service to check against + Returns: + bool: True if the entity matches the DoS service, False otherwise + """ + if None in (self.odscode, dos_service.odscode): + return False + + if dos_service.typeid in PHARMACY_SERVICE_TYPE_IDS: + return ( + len(dos_service.odscode) >= 5 # noqa: PLR2004 + and len(self.odscode) >= 5 # noqa: PLR2004 + and dos_service.odscode[:5] == self.odscode[:5] + ) + + logger.warning(f"Failed nhs code match check for unknown typeid '{dos_service.typeid}'") + return False + + +def is_std_opening_json(item: dict) -> bool: + """Checks EXACT match to definition of General/Standard opening time for NHS Open time payload object.""" # Check values if ( str(item.get("OpeningTimeType")).upper() != "GENERAL" or str(item.get("Weekday")).lower() not in WEEKDAYS or item.get("AdditionalOpeningDate") not in [None, ""] ): - return False is_open = item.get("IsOpen") @@ -171,15 +238,13 @@ def is_std_opening_json(item: dict) -> bool: return False # If marked as closed, ensure open time values are not present - if not is_open and (any(value not in ["", None] for value in (open_time, close_time))): - return False - - return True + return bool( + is_open or all(value in ["", None] for value in (open_time, close_time)), + ) def is_spec_opening_json(item: dict) -> bool: - """Checks EXACT match to definition of Additional/Spec opening time for NHS Open time payload object""" - + """Checks EXACT match to definition of Additional/Spec opening time for NHS Open time payload object.""" if str(item.get("OpeningTimeType")).upper() != "ADDITIONAL": return False @@ -200,7 +265,28 @@ def is_spec_opening_json(item: dict) -> bool: return False # If marked as closed, ensure open time values are not present - if not is_open and (any(value not in ["", None] for value in (open_time, close_time))): - return False + return bool( + is_open or all(value in ["", None] for value in (open_time, close_time)), + ) + + +def skip_if_key_is_none(key: None | str | bool | int) -> bool: + """If the key is None, skip the item.""" + return key is None - return True + +def get_palliative_care_log_value(palliative_care: bool, skip_palliative_care: bool) -> bool | str: + """Get the value to log for palliative care. + + Args: + palliative_care (bool): The value of palliative care + skip_palliative_care (bool): Whether to skip palliative care + + Returns: + bool | str: The value to log + """ + return ( + "Never been updated on Profile Manager, skipped palliative care checks" + if skip_palliative_care + else palliative_care + ) diff --git a/application/common/opening_times.py b/application/common/opening_times.py index 1599da30b..5d6f01ace 100644 --- a/application/common/opening_times.py +++ b/application/common/opening_times.py @@ -1,63 +1,134 @@ +from contextlib import suppress from dataclasses import dataclass -from datetime import date, time, datetime -from typing import Any, Dict, List, Union -import re +from datetime import date, datetime, time +from typing import Any, Optional, Self -from aws_lambda_powertools import Logger +from aws_lambda_powertools.logging import Logger logger = Logger(child=True) WEEKDAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday") -CHANGE_REQUEST_DATE_FORMAT = "%Y-%m-%d" -CHANGE_REQUEST_TIME_FORMAT = "%H:%M" +DAY_IDS = (1, 2, 3, 4, 5, 6, 7) +DOS_DATE_FORMAT = "%Y-%m-%d" +DOS_TIME_FORMAT = "%H:%M" -@dataclass(repr=True, unsafe_hash=True) +@dataclass(unsafe_hash=True, init=True) class OpenPeriod: - def __init__(self, start: time, end: time): - assert isinstance(start, time) and isinstance(end, time) - self.start = start - self.end = end + """Represents a period of time when a service is open. - def start_string(self) -> str: + Attributes: + start (time): The start time of the open period + end (time): The end time of the open period + """ + + start: time + end: time + + def start_string(self: Self) -> str: + """Get the start time as a string. + + Returns: + str: The start time as a string + """ return self.start.strftime("%H:%M:%S") - def end_string(self) -> str: + def end_string(self: Self) -> str: + """Get the end time as a string. + + Returns: + str: The end time as a string + """ return self.end.strftime("%H:%M:%S") - def __str__(self): + def __str__(self: Self) -> str: + """Get the open period as a string. + + Returns: + str: The open period as a string + """ return f"{self.start_string()}-{self.end_string()}" - def __eq__(self, other: Any): + def __repr__(self: Self) -> str: + """Get the open period as a string. + + Returns: + str: The open period as a string + """ + return f"OpenPeriod({self})" + + def __eq__(self: Self, other: object) -> bool: + """Check if two OpenPeriod objects are equal. + + Args: + other (Any): The object to compare to + + Returns: + bool: True if the objects are equal, False otherwise + """ return isinstance(other, OpenPeriod) and self.start == other.start and self.end == other.end - def __lt__(self, other: Any): + def __lt__(self: Self, other: Any) -> bool: # noqa: ANN401 + """Check if one OpenPeriod object is less than another. + + Args: + other (Any): The object to compare to + + Returns: + bool: True if the first object is less than the second, False otherwise + """ if self.start == other.start: return self.end < other.end return self.start < other.start - def __gt__(self, other: Any): + def __gt__(self: Self, other: Any) -> bool: # noqa: ANN401 + """Check if one OpenPeriod object is less than another. + + Args: + other (Any): The object to compare to + + Returns: + bool: True if the first object is less than the second, False otherwise + """ if self.start == other.start: return self.end > other.end return self.start > other.start - def start_before_end(self) -> bool: + def start_before_end(self: Self) -> bool: + """Check if the start time is before the end time. + + Returns: + bool: True if the start time is before the end time, False otherwise + """ return self.start < self.end - def overlaps(self, other) -> bool: - assert self.start_before_end() - assert other.start_before_end() - return self.start < other.end and other.start < self.end + def overlaps(self: Self, other: Any) -> bool: # noqa: ANN401 + """Check if two OpenPeriod objects overlap. - def export_cr_format(self) -> Union[Dict[str, str], None]: - """Exports open period into a DoS change request accepted format""" - return { - "start_time": self.start.strftime(CHANGE_REQUEST_TIME_FORMAT), - "end_time": self.end.strftime(CHANGE_REQUEST_TIME_FORMAT), - } + Args: + other (Any): The object to compare to + + Returns: + bool: True if the objects overlap, False otherwise + """ + assert self.start_before_end() # noqa: S101 + assert other.start_before_end() # noqa: S101 + return self.start <= other.end and other.start <= self.end + + def export_db_string_format(self: Self) -> str: + """Exports open period into a DoS db accepted format for previous value in the service history entry.""" + return f"{self.start.strftime(DOS_TIME_FORMAT)}-{self.end.strftime(DOS_TIME_FORMAT)}" + + def export_time_in_seconds(self: Self) -> str: + """Exports open period into a DoS DB accepted format for service history.""" + return f"{self._seconds_since_midnight(self.start)}-{self._seconds_since_midnight(self.end)}" + + def _seconds_since_midnight(self: Self, time: time) -> int: + """Returns the number of seconds since midnight for the given time.""" + return time.hour * 60 * 60 + time.minute * 60 + time.second @staticmethod - def any_overlaps(open_periods: List["OpenPeriod"]) -> bool: - """Returns whether any OpenPeriod object in list overlaps any others in the list""" + def any_overlaps(open_periods: list["OpenPeriod"]) -> bool: + """Returns whether any OpenPeriod object in list overlaps any others in the list.""" untested = open_periods.copy() while len(untested) > 1: test_op = untested.pop(0) @@ -67,55 +138,28 @@ def any_overlaps(open_periods: List["OpenPeriod"]) -> bool: return False @staticmethod - def list_string(open_periods: List["OpenPeriod"]) -> str: - """Returns a string version of a list of open periods in a consistently sorted order + def list_string(open_periods: list["OpenPeriod"]) -> str: + """Returns a string version of a list of open periods in a consistently sorted order. eg. '[08:00:00-13:00:00, 14:00:00-17:00:00, 18:00:00-20:00:00] """ - sorted_str_list = [str(op) for op in sorted(list(open_periods))] + sorted_str_list = [str(op) for op in sorted(open_periods)] return f"[{', '.join(sorted_str_list)}]" @staticmethod - def all_start_before_end(open_periods: List["OpenPeriod"]) -> bool: - """Returns whether all OpenPeriod object in list start before they ends""" - for op in open_periods: - if not op.start_before_end(): - return False - return True + def all_start_before_end(open_periods: list["OpenPeriod"]) -> bool: + """Returns whether all OpenPeriod object in list start before they ends.""" + return all(op.start_before_end() for op in open_periods) @staticmethod - def equal_lists(a: List["OpenPeriod"], b: List["OpenPeriod"]) -> bool: - """Checks equality between 2 lists of open periodsRelies on sorting and eq functions in OpenPeriod""" + def equal_lists(a: list["OpenPeriod"], b: list["OpenPeriod"]) -> bool: + """Checks equality between 2 lists of open periodsRelies on sorting and eq functions in OpenPeriod.""" return sorted(a) == sorted(b) @staticmethod - def from_string(open_period_string: str) -> Union["OpenPeriod", None]: - """Builds an OpenPeriod object from a string that's in 1 of 2 formats.""" - - if not isinstance(open_period_string, str): - return None - - # regex looks for HH:MM-HH:MM time format - if re.match(r"^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]\-(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$", open_period_string): - - start, end = [datetime.strptime(time_str, "%H:%M").time() for time_str in open_period_string.split("-")] - return OpenPeriod(start, end) - - # regex looks for HH:MM:SS-HH:MM:SS time format - if re.match( - r"^(?:[01]\d|2[0-3]):(?:[0-5]\d):(?:[0-5]\d)\-(?:[01]\d|2[0-3]):(?:[0-5]\d):(?:[0-5]\d)$", - open_period_string, - ): - - start, end = [datetime.strptime(time_str, "%H:%M:%S").time() for time_str in open_period_string.split("-")] - return OpenPeriod(start, end) - - return None - - @staticmethod - def from_string_times(opening_time_str: str, closing_time_str: str) -> Union["OpenPeriod", None]: - """Builds an OpenPeriod object from string time arguments""" + def from_string_times(opening_time_str: str, closing_time_str: str) -> Optional["OpenPeriod"]: + """Builds an OpenPeriod object from string time arguments.""" open_time = string_to_time(opening_time_str) close_time = string_to_time(closing_time_str) if None in (open_time, close_time): @@ -123,27 +167,87 @@ def from_string_times(opening_time_str: str, closing_time_str: str) -> Union["Op return OpenPeriod(open_time, close_time) + def export_test_format(self: Self) -> dict[str, str]: + """Exports open period for use in the DoS DB Hander.""" + return { + "start_time": self.start.strftime(DOS_TIME_FORMAT), + "end_time": self.end.strftime(DOS_TIME_FORMAT), + } + class SpecifiedOpeningTime: - def __init__(self, open_periods: List[OpenPeriod], specified_date: date, is_open: bool = True): - assert isinstance(specified_date, date) + """A class to represent a specified opening time for a service.""" + + def __init__(self: Self, open_periods: list[OpenPeriod], specified_date: date, is_open: bool = True) -> None: + """Initialise a SpecifiedOpeningTime object. + + Args: + open_periods (list[OpenPeriod]): A list of OpenPeriod objects + specified_date (date): The date the open periods apply to + is_open (bool, optional): Whether the service is open on the specified date. Defaults to True. + """ + assert isinstance(specified_date, date) # noqa: S101 self.open_periods = open_periods self.date = specified_date self.is_open = is_open - def date_string(self) -> str: + def date_string(self: Self) -> str: + """Returns the date as a string in the format DD-MM-YYYY. + + Returns: + str: The date as a string in the format DD-MM-YYYY + """ return self.date.strftime("%d-%m-%Y") - def open_periods_string(self) -> str: + def open_periods_string(self: Self) -> str: + """Returns a string version of the open periods. + + Returns: + str: A string version of the open periods + """ return OpenPeriod.list_string(self.open_periods) - def __hash__(self): + def __hash__(self: Self) -> int: + """Returns a hash of the object. + + Returns: + int: A hash of the object + """ return hash((tuple(sorted(self.open_periods)), self.date, self.is_open)) - def __repr__(self): + def __repr__(self: Self) -> str: + """Returns a string representation of the object. + + Returns: + str: A string representation of the object + """ return f"" - def __eq__(self, other): + def __str__(self: Self) -> str: + """Returns a string representation of the object. + + Returns: + str: A string representation of the object + """ + return f"{self.open_string()} on {self.date_string()} {self.open_periods_string()}" + + def open_string(self: Self) -> str: + """Returns a string representation of whether the service is open or closed. + + Returns: + str: A string representation of whether the service is open or closed + """ + return "OPEN" if self.is_open else "CLOSED" + + def __eq__(self: Self, other: object) -> bool: + """Checks equality between 2 SpecifiedOpeningTime objects. + + Args: + other (Any): The object to compare to + + Returns: + bool: Whether the objects are equal + """ return ( isinstance(other, SpecifiedOpeningTime) and self.is_open == other.is_open @@ -151,52 +255,78 @@ def __eq__(self, other): and OpenPeriod.equal_lists(self.open_periods, other.open_periods) ) - def export_cr_format(self) -> dict: - """Exports Specified opening time into a DoS change request accepted format""" - exp_open_periods = [op.export_cr_format() for op in sorted(self.open_periods)] - date_str = self.date.strftime(CHANGE_REQUEST_DATE_FORMAT) - change = {date_str: exp_open_periods} - return change + def export_service_history_format(self: Self) -> list[str]: + """Exports Specified opening time into a DoS service history accepted format.""" + exp_open_periods = [op.export_time_in_seconds() for op in sorted(self.open_periods)] + date_str = self.date.strftime(DOS_DATE_FORMAT) + return [f"{date_str}-{period}" for period in exp_open_periods] if self.is_open else [f"{date_str}-closed"] + + def export_dos_log_format(self: Self) -> list[str]: + """Exports Specified opening times into a DoS Logs accepted format.""" + exp_open_periods = [op.export_db_string_format() for op in sorted(self.open_periods)] + date_str = self.date.strftime(DOS_DATE_FORMAT) + return [f"{date_str}-{period}" for period in exp_open_periods] if self.is_open else [f"{date_str}-closed"] - def contradiction(self) -> bool: + def contradiction(self: Self) -> bool: """Returns whether the open flag contradicts the number of open periods present.""" return self.is_open != (len(self.open_periods) > 0) - def any_overlaps(self) -> bool: + def any_overlaps(self: Self) -> bool: + """Returns whether any of the open periods overlap.""" return OpenPeriod.any_overlaps(self.open_periods) - def all_start_before_end(self) -> bool: + def all_start_before_end(self: Self) -> bool: + """Returns whether all open periods start before they end.""" return OpenPeriod.all_start_before_end(self.open_periods) - def is_valid(self) -> bool: + def is_valid(self: Self) -> bool: """Validates no overlaps, 'starts before ends' and contradictions.""" return self.all_start_before_end() and (not self.any_overlaps()) and (not self.contradiction()) @staticmethod - def export_cr_format_list(spec_opening_dates: List["SpecifiedOpeningTime"]) -> dict: - """Runs the export_cr_format on a list of SpecifiedOpeningTime objects and combines the results""" - opening_dates_cr_format = {} - for spec_open_date in spec_opening_dates: - spec_open_date_payload = spec_open_date.export_cr_format() - opening_dates_cr_format.update(spec_open_date_payload) - return opening_dates_cr_format + def equal_lists(a: list["SpecifiedOpeningTime"], b: list["SpecifiedOpeningTime"]) -> bool: + """Checks equality between 2 lists of SpecifiedOpeningTime. - @staticmethod - def equal_lists(a: List["SpecifiedOpeningTime"], b: List["SpecifiedOpeningTime"]) -> bool: - """Checks equality between 2 lists of SpecifiedOpeningTime Relies on equality, - and hash functions of SpecifiedOpeningTime""" + Checks equality between 2 lists of SpecifiedOpeningTime Relies on equality, + and hash functions of SpecifiedOpeningTime. + """ hash_list_a = [hash(a) for a in a] hash_list_b = [hash(b) for b in b] return sorted(hash_list_a) == sorted(hash_list_b) @staticmethod - def valid_list(list: List["SpecifiedOpeningTime"]) -> bool: - return all([x.is_valid() for x in list]) + def valid_list(times_list: list["SpecifiedOpeningTime"]) -> bool: + """Checks whether a list of SpecifiedOpeningTime is valid.""" + return all(x.is_valid() for x in times_list) + + @staticmethod + def remove_past_dates( + times_list: list["SpecifiedOpeningTime"], + date_now: Any = None, # noqa: ANN401 + ) -> list["SpecifiedOpeningTime"]: + """Removes any SpecifiedOpeningTime objects from the list that are in the past.""" + if date_now is None: + date_now = datetime.now().date() # noqa: DTZ005 + return [item for item in times_list if item.date >= date_now] + + def export_test_format(self: Self) -> dict: + """Exports Specified opening time into a test format that can be used in the tests.""" + exp_open_periods = [op.export_test_format() for op in sorted(self.open_periods)] + date_str = self.date.strftime(DOS_DATE_FORMAT) + return {date_str: exp_open_periods} + + @staticmethod + def export_test_format_list(spec_opening_dates: list["SpecifiedOpeningTime"]) -> dict: + """Runs the export_test_format on a list of SpecifiedOpeningTime objects and combines the results.""" + opening_dates_cr_format = {} + for spec_open_date in spec_opening_dates: + spec_open_date_payload = spec_open_date.export_test_format() + opening_dates_cr_format |= spec_open_date_payload + return opening_dates_cr_format -@dataclass(unsafe_hash=True) class StandardOpeningTimes: - """Represents the standard openings times for a week. Structured as a set of OpenPeriods per day + """Represents the standard openings times for a week. Structured as a set of OpenPeriods per day. monday: [OpenPeriod1, OpenPeriod2] tuesday: [OpenPeriod1] @@ -206,41 +336,48 @@ class StandardOpeningTimes: An empty list that no open periods means CLOSED """ - def __init__(self): - # Initialise all weekday OpenPeriod lists as empty + def __init__(self: Self) -> None: + """Initialises the StandardOpeningTimes object with empty lists for each day.""" for day in WEEKDAYS: setattr(self, day, []) self.generic_bankholiday = [] self.explicit_closed_days = set() - def __repr__(self): - day_opening_strs = [f"{day}={OpenPeriod.list_string(getattr(self, day))}" for day in WEEKDAYS] - + def __repr__(self: Self) -> str: + """Returns a string representation of the StandardOpeningTimes object.""" closed_days_str = "" if len(self.explicit_closed_days) > 0: closed_days_str = f" exp_closed_days={self.explicit_closed_days}" - return f"" + return f"" - def __len__(self): - return sum([len(getattr(self, day)) for day in WEEKDAYS]) + def __str__(self: Self) -> str: + """Returns a string representation of the StandardOpeningTimes object.""" + return self.to_string(", ") - def __eq__(self, other: "StandardOpeningTimes"): - """Check equality of 2 StandardOpeningTimes (generic bankholiday values are ignored)""" + def __len__(self: Self) -> int: + """Returns the number of OpenPeriods in the StandardOpeningTimes object.""" + return sum(len(getattr(self, day)) for day in WEEKDAYS) + def __eq__(self: Self, other: "StandardOpeningTimes") -> bool: + """Check equality of 2 StandardOpeningTimes (generic bankholiday values are ignored).""" if not isinstance(other, StandardOpeningTimes): return False if self.all_closed_days() != other.all_closed_days(): return False - for day in WEEKDAYS: - if not OpenPeriod.equal_lists(getattr(self, day), getattr(other, day)): - return False + return all(OpenPeriod.equal_lists(self.get_openings(day), other.get_openings(day)) for day in WEEKDAYS) + + def to_string(self: Self, seperator: str = ", ") -> str: + """Returns a string representation of the StandardOpeningTimes object.""" + return seperator.join([f"{day}={OpenPeriod.list_string(getattr(self, day))}" for day in WEEKDAYS]) - return True + def get_openings(self: Self, day: str) -> list[OpenPeriod]: + """Returns the list of OpenPeriods for the given day.""" + return getattr(self, day.lower()) - def all_closed_days(self): + def all_closed_days(self: Self) -> list[str]: """Returns a set of all implicit AND explicit closed days.""" all_closed_days = self.explicit_closed_days @@ -251,11 +388,20 @@ def all_closed_days(self): return all_closed_days - def is_open(self, weekday: str) -> bool: + def fully_closed(self: Self) -> bool: + """Returns whether the object contains any openings.""" + return all(len(getattr(self, day)) <= 0 for day in WEEKDAYS) + + def is_open(self: Self, weekday: str) -> bool: + """Returns whether the object contains any openings for the given day.""" return len(getattr(self, weekday)) > 0 - def add_open_period(self, open_period: OpenPeriod, weekday: str) -> None: - """Adds a formatted open period to the specified weekda + def same_openings(self: Self, other: "StandardOpeningTimes", day: str) -> bool: + """Returns whether the object contains the same openings for the given day.""" + return OpenPeriod.equal_lists(self.get_openings(day), other.get_openings(day)) + + def add_open_period(self: Self, open_period: OpenPeriod, weekday: str) -> None: + """Adds a formatted open period to the specified weekday. Args: open_period (OpenPeriod): The open period to add @@ -270,41 +416,58 @@ def add_open_period(self, open_period: OpenPeriod, weekday: str) -> None: else: logger.error(f"Cannot add opening time for invalid weekday '{weekday}', open period not added.") - def any_overlaps(self): - for weekday in WEEKDAYS: - if OpenPeriod.any_overlaps(getattr(self, weekday)): - return True - return False + def any_overlaps(self: Self) -> bool: + """Returns True if any open period overlaps with another open period.""" + return any(OpenPeriod.any_overlaps(getattr(self, weekday)) for weekday in WEEKDAYS) - def all_start_before_end(self): - for weekday in WEEKDAYS: - if not OpenPeriod.all_start_before_end(getattr(self, weekday)): - return False - return True + def all_start_before_end(self: Self) -> bool: + """Returns True if all open periods start before they end.""" + return all(OpenPeriod.all_start_before_end(getattr(self, weekday)) for weekday in WEEKDAYS) - def any_contradictions(self) -> bool: + def any_contradictions(self: Self) -> bool: """Returns True if any open period falls on a day that is marked as closed.""" - for weekday in self.explicit_closed_days: - if self.is_open(weekday): - return True - return False + return any(self.is_open(weekday) for weekday in self.explicit_closed_days) - def is_valid(self) -> bool: + def is_valid(self: Self) -> bool: + """Returns True if the object is valid.""" return self.all_start_before_end() and not self.any_overlaps() and not self.any_contradictions() - def export_cr_format(self) -> Dict[str, List[Dict[str, str]]]: - """Exports standard opening times into a DoS change request accepted format""" + def export_opening_times_for_day(self: Self, weekday: str) -> list[str]: + """Exports standard opening times into DoS format for a specific day in the week.""" + open_periods = sorted(getattr(self, weekday)) + return [open_period.export_db_string_format() for open_period in open_periods] + + def export_opening_times_in_seconds_for_day(self: Self, weekday: str) -> list[str]: + """Exports standard opening times into time in seconds format for a specific day in the week.""" + open_periods = sorted(getattr(self, weekday)) + return [open_period.export_time_in_seconds() for open_period in open_periods] + + def export_test_format(self: Self) -> dict[str, list[dict[str, str]]]: + """Exports standard opening times into a test format.""" change = {} for weekday in WEEKDAYS: open_periods = sorted(getattr(self, weekday)) - change[weekday.capitalize()] = [op.export_cr_format() for op in open_periods] + change[weekday.capitalize()] = [op.export_test_format() for op in open_periods] return change -def string_to_time(time_str: str) -> time: +def opening_period_times_from_list(open_periods: list[OpenPeriod], with_space: bool = True) -> str: + """Converts a list of OpenPeriods into a string of times separated by a space. + + Args: + open_periods (List[OpenPeriod]): The list of OpenPeriods to convert + with_space (bool): Whether to add a space between each time + """ + return ( + ", ".join([open_period.export_db_string_format() for open_period in open_periods]) + if with_space + else ",".join([open_period.export_db_string_format() for open_period in open_periods]) + ) + + +def string_to_time(time_str: str) -> time | None: + """Converts a string to a time object.""" for time_format in ("%H:%M", "%H:%M:%S"): - try: + with suppress(ValueError): return datetime.strptime(str(time_str), time_format).time() - except ValueError: - pass return None diff --git a/application/common/secretsmanager.py b/application/common/secretsmanager.py index de7bcdb7d..eaf9711d9 100644 --- a/application/common/secretsmanager.py +++ b/application/common/secretsmanager.py @@ -1,7 +1,6 @@ from json import loads -from typing import Dict -from aws_lambda_powertools import Logger +from aws_lambda_powertools.logging import Logger from boto3 import client from botocore.exceptions import ClientError @@ -10,8 +9,8 @@ secrets_manager = client(service_name="secretsmanager") -def get_secret(secret_name: str) -> Dict[str, str]: - """Get the secret from AWS Secrets Manager +def get_secret(secret_name: str) -> dict[str, str]: + """Get the secret from AWS Secrets Manager. Args: secret_name (str): Secret name to get @@ -25,7 +24,7 @@ def get_secret(secret_name: str) -> Dict[str, str]: try: secret_value_response = secrets_manager.get_secret_value(SecretId=secret_name) except ClientError as err: - raise Exception(f"Failed getting secret '{secret_name}' from secrets manager") from err + msg = f"Failed getting secret '{secret_name}' from secrets manager" + raise Exception(msg) from err # noqa: TRY002 secrets_json_str = secret_value_response["SecretString"] - secrets = loads(secrets_json_str) - return secrets + return loads(secrets_json_str) diff --git a/application/common/service_type.py b/application/common/service_type.py deleted file mode 100644 index 0051920f0..000000000 --- a/application/common/service_type.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import List - -from aws_lambda_powertools import Logger -from common.appconfig import AppConfig -from common.change_event_exceptions import ValidationException -from common.constants import ( - DENTIST_ORG_TYPE_ID, - ORGANISATION_SUB_TYPES_KEY, - PHARMACY_ORG_TYPE_ID, - SERVICE_TYPES, - SERVICE_TYPES_ALIAS_KEY, - VALID_SERVICE_TYPES_KEY, -) - -logger = Logger(child=True) - - -def validate_organisation_keys(org_type_id: str, org_sub_type: str) -> None: - """Validate the organisation type id and organisation sub type - - Args: - org_type_id (str): organisation type id - org_sub_type (str): organisation sub type - - Raises: - ValidationException: Either Org Type ID or Org Sub Type is not part of the valid list - """ - validate_organisation_type_id(org_type_id) - if org_sub_type in SERVICE_TYPES[org_type_id][ORGANISATION_SUB_TYPES_KEY]: - logger.info(f"Subtype type id: {org_sub_type} validated") - else: - raise ValidationException(f"Unexpected Org Sub Type ID: '{org_sub_type}'") - - -def validate_organisation_type_id(org_type_id: str) -> None: - """Check if the organisation type id is valid - - Args: - org_type_id (str): organisation type id - """ - app_config = AppConfig("event-processor") - feature_flags = app_config.get_feature_flags() - in_accepted_org_types: bool = feature_flags.evaluate( - name="accepted_org_types", context={"org_type": org_type_id}, default=False - ) - logger.debug(f"Accepted org types: {in_accepted_org_types}") - if ( - org_type_id == PHARMACY_ORG_TYPE_ID - and in_accepted_org_types - or org_type_id == DENTIST_ORG_TYPE_ID - and in_accepted_org_types - ): - logger.append_keys(service_type_alias=SERVICE_TYPES[org_type_id][SERVICE_TYPES_ALIAS_KEY]) - logger.info( - f"Org type id: {org_type_id} validated", - extra={"in_accepted_org_types": in_accepted_org_types}, - ) - else: - logger.append_keys(in_accepted_org_types=in_accepted_org_types) - logger.append_keys(app_config=app_config.get_raw_configuration()) - raise ValidationException(f"Unexpected Org Type ID: '{org_type_id}'") - - -def get_valid_service_types(organisation_type_id: str) -> List[int]: - """Get the valid service types for the organisation type id - - Args: - organisation_type_id (str): organisation type id from nhs uk entity - - Returns: - list[int]: set of valid service types - """ - return SERVICE_TYPES[organisation_type_id][VALID_SERVICE_TYPES_KEY] diff --git a/application/common/tests/conftest.py b/application/common/tests/conftest.py deleted file mode 100644 index 651fd0a05..000000000 --- a/application/common/tests/conftest.py +++ /dev/null @@ -1,101 +0,0 @@ -import json -import os -from random import choices, randint, uniform - -import boto3 -from moto import mock_dynamodb2 -from pytest import fixture - -from ..dos import DoSLocation, DoSService -from ..opening_times import StandardOpeningTimes - -std_event_path = "event_processor/tests/STANDARD_EVENT.json" - -with open(std_event_path, "r") as file: - PHARMACY_STANDARD_EVENT = json.load(file) - - -def dummy_dos_service() -> DoSService: - """Creates a DoSService Object with random data for the unit testing""" - test_data = {} - for col in DoSService.field_names(): - random_str = "".join(choices("ABCDEFGHIJKLM", k=8)) - test_data[col] = random_str - dos_service = DoSService(test_data) - dos_service._standard_opening_times = StandardOpeningTimes() - dos_service._specified_opening_times = [] - return dos_service - - -def dummy_dos_location() -> DoSLocation: - """Creates a DoSLocation Object with random data for the unit testing""" - return DoSLocation( - id=randint(1111, 9999), - postcode="".join(choices("01234567890ABCDEFGHIJKLM", k=6)), - easting=randint(1111, 9999), - northing=randint(1111, 9999), - latitude=uniform(-200.0, 200.0), - longitude=uniform(-200.0, 200.0), - postaltown="".join(choices("ABCDEFGHIJKLM", k=8)), - ) - - -@fixture -def change_event(): - change_event = PHARMACY_STANDARD_EVENT.copy() - yield change_event - - -@fixture -def aws_credentials(): - """Mocked AWS Credentials for moto.""" - os.environ["AWS_ACCESS_KEY_ID"] = "testing" - os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" - os.environ["AWS_SECURITY_TOKEN"] = "testing" - os.environ["AWS_SESSION_TOKEN"] = "testing" - os.environ["CHANGE_EVENTS_TABLE_NAME"] = "CHANGE_EVENTS_TABLE" - os.environ["AWS_REGION"] = "us-east-2" - - -@fixture -def dynamodb_client(aws_credentials): - with mock_dynamodb2(): - conn = boto3.client("dynamodb", region_name=os.environ["AWS_REGION"]) - yield conn - - -@fixture -def dead_letter_message(): - yield { - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "Test message.", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDAIENQZJOLO23YVJ4VO", - "ApproximateFirstReceiveTimestamp": "1545082649185", - }, - "messageAttributes": { - "error_msg": { - "stringValue": "ApiDestination returned HTTP status 400 with payload: Dummy", - "stringListValues": [], - "binaryListValues": [], - "dataType": "String", - }, - "error_msg_http_code": { - "stringValue": "400", - "stringListValues": [], - "binaryListValues": [], - "dataType": "String", - }, - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:cr-fifo-dlq-queue", - "awsRegion": "us-east-2", - } - ] - } diff --git a/application/common/tests/test_appconfig.py b/application/common/tests/test_appconfig.py deleted file mode 100644 index c260aa22b..000000000 --- a/application/common/tests/test_appconfig.py +++ /dev/null @@ -1,36 +0,0 @@ -from unittest.mock import patch -from common.appconfig import AppConfig -from os import environ - -FILE_PATH = "common.appconfig" - - -@patch(f"{FILE_PATH}.AppConfigStore") -def test_app_config(mock_app_config_store): - # Arrange - environment = "unittest" - environ["ENV"] = environment - feature_flags_name = "event-processor" - # Act - AppConfig(feature_flags_name) - # Assert - mock_app_config_store.assert_called_once_with( - environment=environment, application=f"uec-dos-int-{environment}-lambda-app-config", name=feature_flags_name - ) - # Clean up - del environ["ENV"] - - -@patch(f"{FILE_PATH}.FeatureFlags") -@patch(f"{FILE_PATH}.AppConfigStore") -def test_app_config_feature_flags(mock_app_config_store, mock_feature_flags): - # Arrange - environment = "unittest" - environ["ENV"] = environment - feature_flags_name = "event-processor" - # Act - AppConfig(feature_flags_name).get_feature_flags() - # Assert - mock_feature_flags.assert_called_once_with(store=mock_app_config_store.return_value) - # Clean up - del environ["ENV"] diff --git a/application/common/tests/test_change_event_exceptions.py b/application/common/tests/test_change_event_exceptions.py deleted file mode 100644 index e2ecf225f..000000000 --- a/application/common/tests/test_change_event_exceptions.py +++ /dev/null @@ -1,9 +0,0 @@ -from pytest import raises - -from ..change_event_exceptions import ValidationException - - -def test_validation_exception(): - # Arrange & Act - with raises(ValidationException): - raise ValidationException("Test") diff --git a/application/common/tests/test_commissioned_service_type.py b/application/common/tests/test_commissioned_service_type.py new file mode 100644 index 000000000..b5efa710f --- /dev/null +++ b/application/common/tests/test_commissioned_service_type.py @@ -0,0 +1,42 @@ +from application.common.commissioned_service_type import BLOOD_PRESSURE, CONTRACEPTION, PALLIATIVE_CARE +from application.common.constants import ( + DOS_BLOOD_PRESSURE_SGSDID, + DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR, + DOS_BLOOD_PRESSURE_SYMPTOM_GROUP, + DOS_BLOOD_PRESSURE_TYPE_ID, + DOS_CONTRACEPTION_SGSDID, + DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR, + DOS_CONTRACEPTION_SYMPTOM_GROUP, + DOS_CONTRACEPTION_TYPE_ID, + DOS_PALLIATIVE_CARE_SGSDID, + DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + DOS_PALLIATIVE_CARE_TYPE_ID, +) + + +def test_blood_pressure_commissioned_service_type() -> None: + assert BLOOD_PRESSURE.TYPE_NAME == "Blood Pressure" + assert BLOOD_PRESSURE.NHS_UK_SERVICE_CODE == "SRV0560" + assert BLOOD_PRESSURE.DOS_TYPE_ID == DOS_BLOOD_PRESSURE_TYPE_ID + assert BLOOD_PRESSURE.DOS_SYMPTOM_GROUP == DOS_BLOOD_PRESSURE_SYMPTOM_GROUP + assert BLOOD_PRESSURE.DOS_SYMPTOM_DISCRIMINATOR == DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR + assert BLOOD_PRESSURE.DOS_SG_SD_ID == DOS_BLOOD_PRESSURE_SGSDID + + +def test_contraception_commissioned_service_type() -> None: + assert CONTRACEPTION.TYPE_NAME == "Contraception" + assert CONTRACEPTION.NHS_UK_SERVICE_CODE == "SRV2000" + assert CONTRACEPTION.DOS_TYPE_ID == DOS_CONTRACEPTION_TYPE_ID + assert CONTRACEPTION.DOS_SYMPTOM_GROUP == DOS_CONTRACEPTION_SYMPTOM_GROUP + assert CONTRACEPTION.DOS_SYMPTOM_DISCRIMINATOR == DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR + assert CONTRACEPTION.DOS_SG_SD_ID == DOS_CONTRACEPTION_SGSDID + + +def test_pallative_care_commissioned_service_type() -> None: + assert PALLIATIVE_CARE.TYPE_NAME == "Palliative Care" + assert PALLIATIVE_CARE.NHS_UK_SERVICE_CODE == "SRV0559" + assert PALLIATIVE_CARE.DOS_TYPE_ID == DOS_PALLIATIVE_CARE_TYPE_ID + assert PALLIATIVE_CARE.DOS_SYMPTOM_GROUP == DOS_PALLIATIVE_CARE_SYMPTOM_GROUP + assert PALLIATIVE_CARE.DOS_SYMPTOM_DISCRIMINATOR == DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR + assert PALLIATIVE_CARE.DOS_SG_SD_ID == DOS_PALLIATIVE_CARE_SGSDID diff --git a/application/common/tests/test_dos.py b/application/common/tests/test_dos.py index 9fd8e138c..2a3aa1ba5 100644 --- a/application/common/tests/test_dos.py +++ b/application/common/tests/test_dos.py @@ -1,24 +1,33 @@ -from datetime import date, datetime, time, timezone +from datetime import UTC, date, datetime, time from random import choices from unittest.mock import MagicMock, patch -import pytest -from .conftest import dummy_dos_location, dummy_dos_service -from common.constants import DENTIST_ORG_TYPE_ID, PHARMACY_ORG_TYPE_ID -from ..opening_times import OpenPeriod, StandardOpeningTimes -from ..dos import ( - DoSLocation, +from application.common.dos import ( DoSService, + db_rows_to_spec_open_times, + db_rows_to_std_open_times, get_dos_locations, get_matching_dos_services, + get_region, get_specified_opening_times_from_db, get_standard_opening_times_from_db, + get_valid_dos_location, + has_blood_pressure, + has_contraception, + has_palliative_care, +) +from application.common.opening_times import OpenPeriod, SpecifiedOpeningTime, StandardOpeningTimes +from application.conftest import dummy_dos_service +from common.constants import ( + DOS_ACTIVE_STATUS_ID, + DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, ) FILE_PATH = "application.common.dos" -def test_field_names(): +def test_field_names() -> None: assert DoSService.field_names() == [ "id", "uid", @@ -28,25 +37,21 @@ def test_field_names(): "town", "postcode", "web", - "email", - "fax", - "nonpublicphone", "typeid", - "parentid", - "subregionid", "statusid", - "createdtime", - "modifiedtime", + "status_name", "publicphone", "publicname", - "servicename", + "service_type_name", + "easting", + "northing", + "latitude", + "longitude", + "region", ] -def test__init__(): - """Pass in random list of values as a mock database row then make sure - they're correctly set as the attributes of the created object. - """ +def test__init__() -> None: # Arrange test_db_row = {} for column in DoSService.field_names(): @@ -59,7 +64,7 @@ def test__init__(): assert getattr(dos_service, field_name) == test_db_row[field_name] -def test__init__public_name(): +def test__init__public_name() -> None: # Arrange & Act test_name = "Test Public Name" dos_service = dummy_dos_service() @@ -72,7 +77,7 @@ def test__init__public_name(): assert test_name in str(dos_service), f"Should return '{test_name}' in string, actually: {dos_service}" -def test__init__name(): +def test__init__name() -> None: # Arrange & Act dos_service = dummy_dos_service() dos_service.uid = 1 @@ -85,7 +90,7 @@ def test__init__name(): assert "Test Name" in str(dos_service), f"Should return 'Test Name' in string, actually: {dos_service}" -def test__init__no_name(): +def test__init__no_name() -> None: # Arrange & Act dos_service = dummy_dos_service() dos_service.uid = 1 @@ -98,137 +103,260 @@ def test__init__no_name(): assert "NO-VALID-NAME" in str(dos_service), f"Should return 'NO-VALID-NAME' in string, actually: {dos_service}" +def test__eq__() -> None: + # Arrange + dos_service = dummy_dos_service() + dos_service.id = 1 + dos_service.uid = 1 + dos_service2 = dummy_dos_service() + dos_service2.id = 1 + dos_service2.uid = 2 + # Act + assert dos_service == dos_service2 + # Assert + + +def test_dos_service_get_region() -> None: + # Arrange + dos_service = dummy_dos_service() + dos_service.region = "Test Region" + # Act + region = dos_service.get_region() + # Assert + assert region == "Test Region" + + +@patch(f"{FILE_PATH}.get_region") +def test_dos_service_get_region_if_none(mock_get_region: MagicMock) -> None: + # Arrange + dos_service = dummy_dos_service() + dos_service.region = "" + mock_get_region.return_value = region_value = "Test Region" + # Act + region = dos_service.get_region() + # Assert + assert region == region_value + mock_get_region.assert_called_once() + + +@patch(f"{FILE_PATH}.connect_to_db_reader") +@patch(f"{FILE_PATH}.query_dos_db") +def test_get_matching_dos_services_pharmacy_services_returned( + mock_query_dos_db: MagicMock, mock_connect_to_db_reader: MagicMock +) -> None: + # Arrange + odscode = "FQ038" + name = "My Pharmacy" + service_id = 22851351399 + db_return = [get_db_item(odscode, name, id=service_id)] + mock_connection = MagicMock() + mock_connect_to_db_reader.return_value.__enter__.return_value = mock_connection + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = db_return + mock_query_dos_db.return_value = mock_cursor + # Act + response = get_matching_dos_services(odscode) + # Assert + service = response[0] + assert service.odscode == odscode + assert service.id == service_id + assert service.name == name + mock_query_dos_db.assert_called_once_with( + connection=mock_connection, + query=( + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid,statusid, ss.name status_name, " + "publicphone, publicname, st.name service_type_name FROM services s LEFT JOIN servicetypes st ON s.typeid " + "= st.id LEFT JOIN servicestatuses ss on s.statusid = ss.id WHERE s.odscode LIKE %(ODS)s AND s.typeid = " + "ANY(%(PHARMACY_SERVICE_TYPE_IDS)s) AND s.statusid = %(ACTIVE_STATUS_ID)s OR s.odscode LIKE %(ODS)s AND " + "s.typeid = ANY(%(PHARMACY_FIRST_SERVICE_TYPE_IDS)s) AND s.statusid = ANY(%(PHARMACY_FIRST_STATUSES)s)" + ), + query_vars={ + "ODS": "FQ038%", + "PHARMACY_SERVICE_TYPE_IDS": [13, 131, 132, 134, 137], + "ACTIVE_STATUS_ID": DOS_ACTIVE_STATUS_ID, + "PHARMACY_FIRST_SERVICE_TYPE_IDS": [148, 149], + "PHARMACY_FIRST_STATUSES": [1, 2, 3], + }, + ) + mock_cursor.fetchall.assert_called_with() + mock_cursor.close.assert_called_with() + + +@patch(f"{FILE_PATH}.connect_to_db_reader") @patch(f"{FILE_PATH}.query_dos_db") -def test_get_matching_dos_services_pharmacy_services_returned(mock_query_dos_db): +def test_get_matching_dos_services_pharmacy_first_services_returned( + mock_query_dos_db: MagicMock, mock_connect_to_db_reader: MagicMock +) -> None: # Arrange odscode = "FQ038" name = "My Pharmacy" - db_return = get_db_item(odscode, name) + service_id = 22851351399 + db_return = [get_db_item(odscode, name, id=service_id)] mock_connection = MagicMock() - mock_connection.fetchall.return_value = db_return - mock_query_dos_db.return_value = mock_connection + mock_connect_to_db_reader.return_value.__enter__.return_value = mock_connection + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = db_return + mock_query_dos_db.return_value = mock_cursor # Act - response = get_matching_dos_services(odscode, PHARMACY_ORG_TYPE_ID) + response = get_matching_dos_services(odscode) # Assert service = response[0] assert service.odscode == odscode - assert service.id == 22851351399 + assert service.id == service_id assert service.name == name mock_query_dos_db.assert_called_once_with( + connection=mock_connection, query=( - "SELECT s.id, uid, s.name, odscode, address, town, postcode, web, email, fax, nonpublicphone, typeid," - " parentid, subregionid, statusid, createdtime, modifiedtime, publicphone, publicname, st.name servicename" - " FROM services s LEFT JOIN servicetypes st ON s.typeid = st.id" - " WHERE odscode LIKE %(ODS)s" + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid,statusid, ss.name status_name, " + "publicphone, publicname, st.name service_type_name FROM services s LEFT JOIN servicetypes st ON s.typeid " + "= st.id LEFT JOIN servicestatuses ss on s.statusid = ss.id WHERE s.odscode LIKE %(ODS)s AND s.typeid = " + "ANY(%(PHARMACY_SERVICE_TYPE_IDS)s) AND s.statusid = %(ACTIVE_STATUS_ID)s OR s.odscode LIKE %(ODS)s AND " + "s.typeid = ANY(%(PHARMACY_FIRST_SERVICE_TYPE_IDS)s) AND s.statusid = ANY(%(PHARMACY_FIRST_STATUSES)s)" ), - vars={"ODS": f"{odscode[0:5]}%"}, + query_vars={ + "ODS": "FQ038%", + "PHARMACY_SERVICE_TYPE_IDS": [13, 131, 132, 134, 137], + "ACTIVE_STATUS_ID": DOS_ACTIVE_STATUS_ID, + "PHARMACY_FIRST_SERVICE_TYPE_IDS": [148, 149], + "PHARMACY_FIRST_STATUSES": [1, 2, 3], + }, ) - mock_connection.fetchall.assert_called_with() - mock_connection.close.assert_called_with() + mock_cursor.fetchall.assert_called_with() + mock_cursor.close.assert_called_with() -def test_any_generic_bankholiday_open_periods(): +def test_any_generic_bankholiday_open_periods() -> None: dos_service = dummy_dos_service() - dos_service._standard_opening_times = StandardOpeningTimes() + dos_service.standard_opening_times = StandardOpeningTimes() op1 = OpenPeriod(time(8, 0, 0), time(13, 0, 0)) op2 = OpenPeriod(time(14, 0, 0), time(18, 0, 0)) assert dos_service.any_generic_bankholiday_open_periods() is False - dos_service._standard_opening_times.add_open_period(op1, "monday") + dos_service.standard_opening_times.add_open_period(op1, "monday") assert dos_service.any_generic_bankholiday_open_periods() is False - dos_service._standard_opening_times.add_open_period(op2, "monday") + dos_service.standard_opening_times.add_open_period(op2, "monday") assert dos_service.any_generic_bankholiday_open_periods() is False - dos_service._standard_opening_times.add_open_period(op1, "tuesday") - dos_service._standard_opening_times.add_open_period(op1, "wednesday") - dos_service._standard_opening_times.add_open_period(op1, "thursday") - dos_service._standard_opening_times.add_open_period(op1, "friday") - dos_service._standard_opening_times.add_open_period(op1, "saturday") - dos_service._standard_opening_times.add_open_period(op1, "sunday") + dos_service.standard_opening_times.add_open_period(op1, "tuesday") + dos_service.standard_opening_times.add_open_period(op1, "wednesday") + dos_service.standard_opening_times.add_open_period(op1, "thursday") + dos_service.standard_opening_times.add_open_period(op1, "friday") + dos_service.standard_opening_times.add_open_period(op1, "saturday") + dos_service.standard_opening_times.add_open_period(op1, "sunday") assert dos_service.any_generic_bankholiday_open_periods() is False - dos_service._standard_opening_times.add_open_period(op1, "bankholiday") + dos_service.standard_opening_times.add_open_period(op1, "bankholiday") assert dos_service.any_generic_bankholiday_open_periods() - dos_service._standard_opening_times.add_open_period(op2, "bankholiday") + dos_service.standard_opening_times.add_open_period(op2, "bankholiday") assert dos_service.any_generic_bankholiday_open_periods() - dos_service._standard_opening_times.generic_bankholiday = [] + dos_service.standard_opening_times.generic_bankholiday = [] assert dos_service.any_generic_bankholiday_open_periods() is False +@patch(f"{FILE_PATH}.connect_to_db_reader") @patch(f"{FILE_PATH}.query_dos_db") -def test_get_matching_dos_services_dentist_services_returned(mock_query_dos_db): - # Arrange - odscode = "V00393a" - name = "My Dental Practice" - db_return = get_db_item(odscode, name) - mock_connection = MagicMock() - mock_connection.fetchall.return_value = db_return - mock_query_dos_db.return_value = mock_connection - ods6_code = "V0393a" - # Act - response = get_matching_dos_services(odscode, DENTIST_ORG_TYPE_ID) - # Assert - service = response[0] - assert service.odscode == odscode - assert service.id == 22851351399 - assert service.name == name - mock_query_dos_db.assert_called_once_with( - query=( - "SELECT s.id, uid, s.name, odscode, address, town, postcode, web, email, fax, nonpublicphone, typeid," - " parentid, subregionid, statusid, createdtime, modifiedtime, publicphone, publicname, st.name servicename" - " FROM services s LEFT JOIN servicetypes st ON s.typeid = st.id" - " WHERE odscode = %(ODS)s or odscode LIKE %(ODS7)s" - ), - vars={"ODS": f"{ods6_code}", "ODS7": f"{odscode}%"}, - ) - mock_connection.fetchall.assert_called_with() - mock_connection.close.assert_called_with() - - -@patch(f"{FILE_PATH}.query_dos_db") -def test_get_matching_dos_services_no_services_returned(mock_query_dos_db): +def test_get_matching_dos_services_no_services_returned( + mock_query_dos_db: MagicMock, mock_connect_to_db_reader: MagicMock +) -> None: # Arrange odscode = "FQ038" db_return = [] mock_connection = MagicMock() - mock_connection.fetchall.return_value = db_return - mock_query_dos_db.return_value = mock_connection + mock_connect_to_db_reader.return_value.__enter__.return_value = mock_connection + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = db_return + mock_query_dos_db.return_value = mock_cursor # Act - response = get_matching_dos_services(odscode, PHARMACY_ORG_TYPE_ID) + response = get_matching_dos_services(odscode) # Assert assert response == [] mock_query_dos_db.assert_called_once_with( + connection=mock_connection, query=( - "SELECT s.id, uid, s.name, odscode, address, town, postcode, web, email, fax, nonpublicphone, typeid," - " parentid, subregionid, statusid, createdtime, modifiedtime, publicphone, publicname, st.name servicename" - " FROM services s LEFT JOIN servicetypes st ON s.typeid = st.id" - " WHERE odscode LIKE %(ODS)s" + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid,statusid, ss.name status_name, " + "publicphone, publicname, st.name service_type_name FROM services s LEFT JOIN servicetypes st ON s.typeid " + "= st.id LEFT JOIN servicestatuses ss on s.statusid = ss.id WHERE s.odscode LIKE %(ODS)s AND s.typeid = " + "ANY(%(PHARMACY_SERVICE_TYPE_IDS)s) AND s.statusid = %(ACTIVE_STATUS_ID)s OR s.odscode LIKE %(ODS)s AND " + "s.typeid = ANY(%(PHARMACY_FIRST_SERVICE_TYPE_IDS)s) AND s.statusid = ANY(%(PHARMACY_FIRST_STATUSES)s)" ), - vars={"ODS": f"{odscode[0:5]}%"}, + query_vars={ + "ODS": "FQ038%", + "PHARMACY_SERVICE_TYPE_IDS": [13, 131, 132, 134, 137], + "ACTIVE_STATUS_ID": DOS_ACTIVE_STATUS_ID, + "PHARMACY_FIRST_SERVICE_TYPE_IDS": [148, 149], + "PHARMACY_FIRST_STATUSES": [1, 2, 3], + }, ) - mock_connection.fetchall.assert_called_with() - mock_connection.close.assert_called_with() + mock_cursor.fetchall.assert_called_with() + mock_cursor.close.assert_called_with() +@patch(f"{FILE_PATH}.connect_to_db_reader") @patch(f"{FILE_PATH}.query_dos_db") -def test_get_specified_opening_times_from_db_times_returned(mock_query_dos_db): +def test_get_specified_opening_times_from_db_times_returned( + mock_query_dos_db: MagicMock, mock_connect_to_db_reader: MagicMock +) -> None: # Arrange mock_connection = MagicMock() + mock_connect_to_db_reader.return_value.__enter__.return_value = mock_connection + mock_cursor = MagicMock() db_return = [ - (28334, date(2019, 5, 6), time(8, 0, 0), time(20, 0, 0), False), - (28334, date(2019, 5, 27), time(8, 0, 0), time(20, 0, 0), False), - (28334, date(2019, 8, 26), time(8, 0, 0), time(20, 0, 0), False), - (28334, date(2019, 8, 26), time(21, 0, 0), time(22, 0, 0), False), - (28334, date(2019, 9, 20), time(0, 0, 0), time(0, 0, 0), True), - (28334, date(2019, 9, 21), time(14, 30, 0), time(16, 0, 0), True), - (28334, date(2019, 5, 6), time(6, 0, 0), time(7, 0, 0), False), + { + "serviceid": 28334, + "date": date(2019, 5, 6), + "starttime": time(8, 0, 0), + "endtime": time(20, 0, 0), + "isclosed": False, + }, + { + "serviceid": 28334, + "date": date(2019, 5, 27), + "starttime": time(8, 0, 0), + "endtime": time(20, 0, 0), + "isclosed": False, + }, + { + "serviceid": 28334, + "date": date(2019, 8, 26), + "starttime": time(8, 0, 0), + "endtime": time(20, 0, 0), + "isclosed": False, + }, + { + "serviceid": 28334, + "date": date(2019, 8, 26), + "starttime": time(21, 0, 0), + "endtime": time(22, 0, 0), + "isclosed": False, + }, + { + "serviceid": 28334, + "date": date(2019, 9, 20), + "starttime": time(0, 0, 0), + "endtime": time(0, 0, 0), + "isclosed": True, + }, + { + "serviceid": 28334, + "date": date(2019, 9, 21), + "starttime": time(14, 30, 0), + "endtime": time(16, 0, 0), + "isclosed": True, + }, + { + "serviceid": 28334, + "date": date(2019, 5, 6), + "starttime": time(6, 0, 0), + "endtime": time(7, 0, 0), + "isclosed": False, + }, ] - mock_connection.fetchall.return_value = db_return - mock_query_dos_db.return_value = mock_connection + mock_cursor.fetchall.return_value = db_return + mock_query_dos_db.return_value = mock_cursor service_id = 123456 expected_responses_set = sorted( [ @@ -237,73 +365,84 @@ def test_get_specified_opening_times_from_db_times_returned(mock_query_dos_db): "", "", "", - ] + ], ) # Act - responses = get_specified_opening_times_from_db(service_id) - responses_str = sorted([str(s) for s in responses]) + responses = get_specified_opening_times_from_db(connection=mock_connection, service_id=service_id) + responses_str = sorted([repr(s) for s in responses]) # Assert assert ( responses_str == expected_responses_set ), f"Should return {expected_responses_set} string, actually: {responses_str}" mock_query_dos_db.assert_called_once_with( - "SELECT ssod.serviceid, ssod.date, ssot.starttime, ssot.endtime, ssot.isclosed " + connection=mock_connection, + query="SELECT ssod.serviceid, ssod.date, ssot.starttime, ssot.endtime, ssot.isclosed " "FROM servicespecifiedopeningdates ssod " "INNER JOIN servicespecifiedopeningtimes ssot " "ON ssod.id = ssot.servicespecifiedopeningdateid " - "WHERE ssod.serviceid = %(service_id)s", - {"service_id": service_id}, + "WHERE ssod.serviceid = %(SERVICE_ID)s", + query_vars={"SERVICE_ID": service_id}, ) +@patch(f"{FILE_PATH}.connect_to_db_reader") @patch(f"{FILE_PATH}.query_dos_db") -def test_get_standard_opening_times_from_db_times_returned(mock_query_dos_db): +def test_get_standard_opening_times_from_db_times_returned( + mock_query_dos_db: MagicMock, mock_connect_to_db_reader: MagicMock +) -> None: # Arrange db_return = [ - (28334, 1, "Tuesday", time(8, 0, 0), time(17, 0, 0)), - (28334, 1, "Friday", time(9, 0, 0), time(11, 30, 0)), - (28334, 1, "Friday", time(13, 0, 0), time(15, 30, 0)), + {"serviceid": 28334, "dayid": 1, "name": "Tuesday", "starttime": time(8, 0, 0), "endtime": time(17, 0, 0)}, + {"serviceid": 28334, "dayid": 1, "name": "Friday", "starttime": time(9, 0, 0), "endtime": time(11, 30, 0)}, + {"serviceid": 28334, "dayid": 1, "name": "Friday", "starttime": time(13, 0, 0), "endtime": time(15, 30, 0)}, ] - mock_connection = MagicMock() + mock_cursor = MagicMock() service_id = 123456 - mock_connection.fetchall.return_value = db_return - mock_query_dos_db.return_value = mock_connection + mock_cursor.fetchall.return_value = db_return + mock_query_dos_db.return_value = mock_cursor expected_std_opening_times = StandardOpeningTimes() expected_std_opening_times.add_open_period(OpenPeriod(time(8, 0, 0), time(17, 0, 0)), "tuesday") expected_std_opening_times.add_open_period(OpenPeriod(time(9, 0, 0), time(11, 30, 0)), "friday") expected_std_opening_times.add_open_period(OpenPeriod(time(13, 0, 0), time(15, 30, 0)), "friday") - + mock_connection = MagicMock() + mock_connect_to_db_reader.return_value.__enter__.return_value = mock_connection # Act - response = get_standard_opening_times_from_db(service_id) + response = get_standard_opening_times_from_db(connection=mock_connection, service_id=service_id) # Assert assert ( response == expected_std_opening_times ), f"Should return {expected_std_opening_times} string, actually: {response}" mock_query_dos_db.assert_called_once_with( - "SELECT sdo.serviceid, sdo.dayid, otd.name, sdot.starttime, sdot.endtime " + connection=mock_connection, + query="SELECT sdo.serviceid, sdo.dayid, otd.name, sdot.starttime, sdot.endtime " "FROM servicedayopenings sdo " "INNER JOIN servicedayopeningtimes sdot " "ON sdo.id = sdot.servicedayopeningid " "LEFT JOIN openingtimedays otd " "ON sdo.dayid = otd.id " - "WHERE sdo.serviceid = %(service_id)s", - {"service_id": service_id}, + "WHERE sdo.serviceid = %(SERVICE_ID)s", + query_vars={"SERVICE_ID": service_id}, ) +@patch(f"{FILE_PATH}.connect_to_db_reader") @patch(f"{FILE_PATH}.query_dos_db") -def test_get_specified_opening_times_from_db_no_times_returned(mock_query_dos_db): +def test_get_specified_opening_times_from_db_no_times_returned( + mock_query_dos_db: MagicMock, mock_connect_to_db_reader: MagicMock +) -> None: # Arrange mock_connection = MagicMock() + mock_connect_to_db_reader.return_value.__enter__.return_value = mock_connection + mock_cursor = MagicMock() db_return = [] - mock_connection.fetchall.return_value = db_return - mock_query_dos_db.return_value = mock_connection + mock_cursor.fetchall.return_value = db_return + mock_query_dos_db.return_value = mock_cursor service_id = 123456 expected_responses_set = sorted([]) # Act - responses = get_specified_opening_times_from_db(service_id) + responses = get_specified_opening_times_from_db(connection=mock_connection, service_id=service_id) responses_str = sorted([str(s) for s in responses]) # Assert assert ( @@ -311,67 +450,37 @@ def test_get_specified_opening_times_from_db_no_times_returned(mock_query_dos_db ), f"Should return {expected_responses_set} string, actually: {responses_str}" mock_query_dos_db.assert_called_once_with( - "SELECT ssod.serviceid, ssod.date, ssot.starttime, ssot.endtime, ssot.isclosed " + connection=mock_connection, + query="SELECT ssod.serviceid, ssod.date, ssot.starttime, ssot.endtime, ssot.isclosed " "FROM servicespecifiedopeningdates ssod " "INNER JOIN servicespecifiedopeningtimes ssot " "ON ssod.id = ssot.servicespecifiedopeningdateid " - "WHERE ssod.serviceid = %(service_id)s", - {"service_id": service_id}, + "WHERE ssod.serviceid = %(SERVICE_ID)s", + query_vars={"SERVICE_ID": service_id}, ) -@pytest.mark.parametrize( - "dos_location, expected_result", - [ - (DoSLocation(id=1, postcode="TE57ER", easting=None, northing=None, latitude=None, longitude=None), False), - (DoSLocation(id=1, postcode="TE57ER", easting=None, northing=1, latitude=1.1, longitude=1.1), False), - (DoSLocation(id=1, postcode="TE57ER", easting=1, northing=None, latitude=1.1, longitude=1.1), False), - (DoSLocation(id=1, postcode="TE57ER", easting=1, northing=1, latitude=None, longitude=1.1), False), - (DoSLocation(id=1, postcode="TE57ER", easting=1, northing=1, latitude=1.1, longitude=None), False), - (DoSLocation(id=1, postcode="TE57ER", easting=None, northing=None, latitude=1.1, longitude=1.1), False), - (DoSLocation(id=1, postcode="TE57ER", easting=1, northing=1, latitude=None, longitude=None), False), - (DoSLocation(id=1, postcode="TE57ER", easting=1, northing=1, latitude=1.1, longitude=1.1), True), - ], -) -def test_doslocation_is_valid(dos_location: DoSLocation, expected_result: bool): - actual_result = dos_location.is_valid() - assert ( - actual_result is expected_result - ), f"is_valued check on {dos_location} was found to be {actual_result}, it should be {expected_result}." - - -@pytest.mark.parametrize( - "input_postcode, expected_result", - [ - ("TE57ER", "TE57ER"), - ("TE5 7ER", "TE57ER"), - ("T E57ER", "TE57ER"), - ("T E57E R", "TE57ER"), - ("T E 5 7 E R", "TE57ER"), - ("TE57ER ", "TE57ER"), - (" TE57ER", "TE57ER"), - ("te5 7er", "TE57ER"), - ("te5 7 e r", "TE57ER"), - ], -) -def test_doslocation_normal_postcode(input_postcode: str, expected_result: str): - dos_location = dummy_dos_location() - dos_location.postcode = input_postcode - actual_output = dos_location.normal_postcode() - assert ( - actual_output == expected_result - ), f"Normalised postcode for '{input_postcode}' is '{actual_output}', it should be '{expected_result}'." - - +@patch(f"{FILE_PATH}.connect_to_db_reader") @patch(f"{FILE_PATH}.query_dos_db") -def test_get_dos_locations(mock_query_dos_db): +def test_get_dos_locations(mock_query_dos_db: MagicMock, mock_connect_to_db_reader: MagicMock) -> None: # Arrange mock_connection = MagicMock() + mock_connect_to_db_reader.return_value.__enter__.return_value = mock_connection + mock_cursor = MagicMock() postcode = "BA2 7AF" - db_return = [{"id": 111, "postcode": postcode, "easting": 2, "northing": 3, "latitude": 4.0, "longitude": 2.0}] - mock_connection.fetchall.return_value = db_return - mock_query_dos_db.return_value = mock_connection - + db_return = [ + { + "id": 111, + "postcode": postcode, + "easting": 2, + "northing": 3, + "postaltown": "town", + "latitude": 4.0, + "longitude": 2.0, + }, + ] + mock_cursor.fetchall.return_value = db_return + mock_query_dos_db.return_value = mock_cursor # Act responses = get_dos_locations(postcode) # Assert @@ -384,35 +493,254 @@ def test_get_dos_locations(mock_query_dos_db): assert dos_location.latitude == 4.0 assert dos_location.longitude == 2.0 + norm_pc = postcode.replace(" ", "").upper() + postcode_variations = [norm_pc] + [f"{norm_pc[:i]} {norm_pc[i:]}" for i in range(1, len(norm_pc))] + mock_query_dos_db.assert_called_once_with( - "SELECT id, postcode, easting, northing, latitude, longitude, postaltown " - "FROM locations WHERE postcode ~* %(pc_regex)s", - {"pc_regex": " *".join(postcode.replace(" ", "").upper())}, + connection=mock_connection, + query="SELECT id, postcode, easting, northing, postaltown, latitude, longitude " + "FROM locations WHERE postcode = ANY(%(pc_variations)s)", + query_vars={"pc_variations": postcode_variations}, ) -def get_db_item(odscode, name): - return [ +@patch(f"{FILE_PATH}.get_dos_locations") +def test_get_valid_dos_location(mock_get_dos_locations: MagicMock) -> None: + # Arrange + mock_get_dos_locations.return_value.is_valid.return_value = True + mock_get_dos_locations.return_value = mock_location = [MagicMock()] + postcode = "BA2 7AF" + # Act + location = get_valid_dos_location(postcode) + # Assert + assert location == mock_location[0] + + +@patch(f"{FILE_PATH}.get_dos_locations") +def test_get_valid_dos_location_invalid_postcode(mock_get_dos_locations: MagicMock) -> None: + # Arrange + mock_get_dos_locations.return_value.is_valid.return_value = False + postcode = "BA2 7AF" + # Act + location = get_valid_dos_location(postcode) + # Assert + assert location is None + + +def test_db_rows_to_spec_open_times() -> None: + db_rows = [ + { + "serviceid": 1, + "date": date(2019, 5, 6), + "starttime": time(8, 0, 0), + "endtime": time(20, 0, 0), + "isclosed": False, + }, { - "id": 22851351399, - "uid": "159514725", - "name": name, - "odscode": odscode, - "address": "80 Street$Town", - "town": "Town", - "postcode": "TES T12", - "web": None, - "email": None, - "fax": None, - "nonpublicphone": None, - "typeid": 13, - "parentid": 123486, - "subregionid": 21813557, - "statusid": 1, - "createdtime": datetime(2011, 8, 24, 9, 17, 24, tzinfo=timezone.utc), - "modifiedtime": datetime(2019, 3, 13, 0, 37, 7, tzinfo=timezone.utc), - "publicphone": "0123 012 012", - "publicname": None, - "servicename": "my service", - } + "serviceid": 1, + "date": date(2019, 5, 6), + "starttime": time(21, 0, 0), + "endtime": time(22, 0, 0), + "isclosed": False, + }, + { + "serviceid": 1, + "date": date(2019, 5, 27), + "starttime": time(8, 0, 0), + "endtime": time(20, 0, 0), + "isclosed": False, + }, + { + "serviceid": 1, + "date": date(2019, 8, 26), + "starttime": time(8, 0, 0), + "endtime": time(20, 0, 0), + "isclosed": False, + }, + {"serviceid": 1, "date": date(2019, 9, 20), "starttime": None, "endtime": None, "isclosed": True}, + { + "serviceid": 1, + "date": date(2020, 5, 6), + "starttime": time(6, 0, 0), + "endtime": time(7, 0, 0), + "isclosed": False, + }, + ] + + spec_open_times = db_rows_to_spec_open_times(db_rows) + + expected_spec_open_times = [ + SpecifiedOpeningTime( + [OpenPeriod.from_string_times("08:00", "20:00"), OpenPeriod.from_string_times("21:00", "22:00")], + date(2019, 5, 6), + True, + ), + SpecifiedOpeningTime([OpenPeriod.from_string_times("08:00", "20:00")], date(2019, 5, 27), True), + SpecifiedOpeningTime([OpenPeriod.from_string_times("08:00", "20:00")], date(2019, 8, 26), True), + SpecifiedOpeningTime([], date(2019, 9, 20), False), + SpecifiedOpeningTime([OpenPeriod.from_string_times("06:00", "07:00")], date(2020, 5, 6), True), + ] + + assert spec_open_times == expected_spec_open_times + + +def test_db_rows_to_std_open_time() -> None: + db_rows = [ + {"serviceid": 1, "dayid": 0, "name": "Monday", "starttime": time(8, 0, 0), "endtime": time(17, 0, 0)}, + {"serviceid": 1, "dayid": 6, "name": "Sunday", "starttime": time(13, 0, 0), "endtime": time(15, 30, 0)}, + {"serviceid": 1, "dayid": 1, "name": "Tuesday", "starttime": time(13, 0, 0), "endtime": time(18, 0, 0)}, + {"serviceid": 1, "dayid": 4, "name": "Friday", "starttime": time(13, 0, 0), "endtime": time(15, 30, 0)}, + {"serviceid": 1, "dayid": 6, "name": "Wednesday", "starttime": time(7, 0, 0), "endtime": time(15, 30, 0)}, + {"serviceid": 1, "dayid": 1, "name": "Tuesday", "starttime": time(8, 0, 0), "endtime": time(12, 0, 0)}, + {"serviceid": 1, "dayid": 4, "name": "Thursday", "starttime": time(11, 0, 0), "endtime": time(13, 30, 0)}, + ] + + expected_std_open_times = StandardOpeningTimes() + expected_std_open_times.monday = [OpenPeriod.from_string_times("08:00", "17:00")] + expected_std_open_times.tuesday = [ + OpenPeriod.from_string_times("08:00", "12:00"), + OpenPeriod.from_string_times("13:00", "18:00"), ] + expected_std_open_times.wednesday = [OpenPeriod.from_string_times("07:00", "15:30")] + expected_std_open_times.thursday = [OpenPeriod.from_string_times("11:00", "13:30")] + expected_std_open_times.friday = [OpenPeriod.from_string_times("13:00", "15:30")] + expected_std_open_times.sunday = [OpenPeriod.from_string_times("13:00", "15:30")] + + actual_std_open_times = db_rows_to_std_open_times(db_rows) + + assert actual_std_open_times == expected_std_open_times + + +def get_db_item(odscode: str = "FA9321", name: str = "fake name", id: int = 9999, typeid: int = 13) -> dict: # noqa: A002 + return { + "id": id, + "uid": "159514725", + "name": name, + "odscode": odscode, + "address": "80 Street$Town", + "town": "Town", + "postcode": "TES T12", + "web": None, + "email": None, + "fax": None, + "nonpublicphone": None, + "typeid": typeid, + "parentid": 123486, + "subregionid": 21813557, + "statusid": 1, + "createdtime": datetime(2011, 8, 24, 9, 17, 24, tzinfo=UTC), + "modifiedtime": datetime(2019, 3, 13, 0, 37, 7, tzinfo=UTC), + "publicphone": "0123 012 012", + "publicname": None, + "service_type_name": "my service", + } + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_has_palliative_care(mock_query_dos_db: MagicMock) -> None: + # Arrange + dos_service = dummy_dos_service() + dos_service.typeid = 13 + connection = MagicMock() + expected_sql_command = """SELECT sgsds.id as z_code from servicesgsds sgsds + WHERE sgsds.serviceid = %(SERVICE_ID)s + AND sgsds.sgid = %(PALLIATIVE_CARE_SYMPTOM_GROUP)s + AND sgsds.sdid = %(PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR)s + """ + expected_named_args = { + "SERVICE_ID": dos_service.id, + "PALLIATIVE_CARE_SYMPTOM_GROUP": DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + "PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR": DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + } + # Act + assert True is has_palliative_care(dos_service, connection) + # Assert + mock_query_dos_db.assert_called_once_with( + connection=connection, + query=expected_sql_command, + query_vars=expected_named_args, + ) + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_has_palliative_care_not_correct_type(mock_query_dos_db: MagicMock) -> None: + # Arrange + dos_service = dummy_dos_service() + dos_service.typeid = 0 + connection = MagicMock() + # Act + assert False is has_palliative_care(dos_service, connection) + # Assert + mock_query_dos_db.assert_not_called() + + +def test_has_blood_pressure() -> None: + # Arrange + dos_service = dummy_dos_service() + dos_service.typeid = 148 + dos_service.statusid = 1 + # Act & Assert + assert True is has_blood_pressure(dos_service) + + +def test_has_blood_pressure_not_correct_type() -> None: + # Arrange + dos_service = dummy_dos_service() + dos_service.typeid = 13 + dos_service.statusid = 1 + # Act & Assert + assert False is has_blood_pressure(dos_service) + + +def test_has_contraception() -> None: + # Arrange + dos_service = dummy_dos_service() + dos_service.typeid = 149 + dos_service.statusid = 1 + # Act & Assert + assert True is has_contraception(dos_service) + + +def test_has_contraception_not_correct_type() -> None: + # Arrange + dos_service = dummy_dos_service() + dos_service.typeid = 13 + dos_service.statusid = 1 + # Act & Assert + assert False is has_contraception(dos_service) + + +@patch(f"{FILE_PATH}.query_dos_db") +@patch(f"{FILE_PATH}.connect_to_db_reader") +def test_get_region(mock_connect_to_db_reader: MagicMock, mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connect_to_db_reader.return_value = mock_connection = MagicMock() + mock_query_dos_db.return_value.fetchone.return_value = {"region": "South East"} + service_id = 123 + # Act + region = get_region(service_id) + # Assert + assert region == "South East" + mock_connect_to_db_reader.assert_called_once() + mock_query_dos_db.assert_called_once_with( + connection=mock_connection.__enter__.return_value, + query="""WITH +RECURSIVE servicetree as +(SELECT ser.parentid, ser.id, ser.uid, ser.name, 1 AS lvl +FROM services ser where ser.id = %(SERVICE_ID)s +UNION ALL +SELECT ser.parentid, st.id, ser.uid, ser.name, lvl+1 AS lvl +FROM services ser +INNER JOIN servicetree st ON ser.id = st.parentid), +serviceregion as +(SELECT st.*, ROW_NUMBER() OVER (PARTITION BY st.id ORDER BY st.lvl desc) rn +FROM servicetree st) +SELECT sr.name region +FROM serviceregion sr +INNER JOIN services ser ON sr.id = ser.id +LEFT OUTER JOIN services par ON ser.parentid = par.id +WHERE sr.rn=1 +ORDER BY ser.name + """, + query_vars={"SERVICE_ID": service_id}, + ) diff --git a/application/common/tests/test_dos_db_connection.py b/application/common/tests/test_dos_db_connection.py index 0e9d5cbec..eac6039ed 100644 --- a/application/common/tests/test_dos_db_connection.py +++ b/application/common/tests/test_dos_db_connection.py @@ -1,56 +1,126 @@ -from os import environ, getenv +from os import environ from unittest.mock import MagicMock, patch -from ..dos_db_connection import _get_db_connection, _set_db_connection, disconnect_dos_db, query_dos_db +from psycopg.rows import dict_row + +from application.common.dos_db_connection import ( + connect_to_db_reader, + connect_to_db_writer, + connection_to_db, + query_dos_db, +) FILE_PATH = "application.common.dos_db_connection" +DB_WRITER_SERVER = "test.db" +DB_READER_SERVER = "test.db" +DB_PORT = "5432" +DB_NAME = "my-db" +DB_SCHEMA = "db_schema" +DB_USER = "my-user" +DB_PASSWORD = "my-password" + +@patch(f"{FILE_PATH}.connection_to_db") @patch(f"{FILE_PATH}.get_secret") -@patch("psycopg2.connect") -def test_query_dos_db(mock_connect, mock_get_secret): +def test_connect_to_db_reader(mock_get_secret: MagicMock, mock_connection_to_db: MagicMock) -> None: # Arrange - environ["DB_SERVER"] = server = "test.db" - environ["DB_PORT"] = port = "5432" - environ["DB_NAME"] = db_name = "my-db" - environ["DB_SCHEMA"] = db_schema = "db_schema" - environ["DB_USER_NAME"] = db_user = "my-user" - environ["DB_SECRET_NAME"] = "my_secret_name" - environ["DB_SECRET_KEY"] = "my_secret_key" - mock_get_secret.return_value = {environ["DB_SECRET_KEY"]: "my-password"} - db_password = mock_get_secret.return_value[environ["DB_SECRET_KEY"]] - query = "SELECT * FROM my_table" - vars = None + mock_get_secret.return_value = {"DB_READER_SECRET_KEY": DB_PASSWORD} + environ["DB_READER_SECRET_NAME"] = "my_secret_name" + environ["DB_READER_SERVER"] = DB_READER_SERVER + environ["DB_PORT"] = DB_PORT + environ["DB_NAME"] = DB_NAME + environ["DB_SCHEMA"] = DB_SCHEMA + environ["DB_READ_ONLY_USER_NAME"] = DB_USER + environ["DB_READER_SECRET_KEY"] = "DB_READER_SECRET_KEY" + environ["DB_SECRET"] = DB_PASSWORD # Act - query_dos_db(query, vars) - # Assert - mock_connect.assert_called_with( - host=server, - port=port, - dbname=db_name, - user=db_user, - password=db_password, - connect_timeout=30, - options=f"-c search_path=dbo,{db_schema}", - application_name=f"DI-Application tid={getenv('_X_AMZN_TRACE_ID', default='')}", + with connect_to_db_reader() as db_connection: + # Assert + assert db_connection is not None + mock_connection_to_db.assert_called_with( + server=DB_READER_SERVER, + port=DB_PORT, + db_name=DB_NAME, + db_schema=DB_SCHEMA, + db_user=DB_USER, + db_password=DB_PASSWORD, ) - mock_get_secret.assert_called_once_with(environ["DB_SECRET_NAME"]) - assert _get_db_connection() is not None # Clean up - del environ["DB_SERVER"] + del environ["DB_READER_SERVER"] del environ["DB_PORT"] del environ["DB_NAME"] del environ["DB_SCHEMA"] - del environ["DB_USER_NAME"] - del environ["DB_SECRET_NAME"] - del environ["DB_SECRET_KEY"] + del environ["DB_READ_ONLY_USER_NAME"] + del environ["DB_READER_SECRET_KEY"] + del environ["DB_READER_SECRET_NAME"] -def test_disconnect_dos_db(): +@patch(f"{FILE_PATH}.connection_to_db") +@patch(f"{FILE_PATH}.get_secret") +def test_connect_to_db_writer(mock_get_secret: MagicMock, mock_connection_to_db: MagicMock) -> None: # Arrange - mock_db_connection = MagicMock() - _set_db_connection(mock_db_connection) + mock_get_secret.return_value = {"DB_WRITER_SECRET_KEY": DB_PASSWORD} + environ["DB_WRITER_SECRET_NAME"] = "my_secret_name" + environ["DB_WRITER_SERVER"] = DB_WRITER_SERVER + environ["DB_PORT"] = DB_PORT + environ["DB_NAME"] = DB_NAME + environ["DB_SCHEMA"] = DB_SCHEMA + environ["DB_READ_AND_WRITE_USER_NAME"] = DB_USER + environ["DB_WRITER_SECRET_KEY"] = "DB_WRITER_SECRET_KEY" + # Act + with connect_to_db_writer() as db_connection: + # Assert + assert db_connection is not None + mock_connection_to_db.assert_called_once_with( + server=DB_WRITER_SERVER, + port=DB_PORT, + db_name=DB_NAME, + db_schema=DB_SCHEMA, + db_user=DB_USER, + db_password=DB_PASSWORD, + ) + # Clean up + del environ["DB_WRITER_SECRET_NAME"] + del environ["DB_WRITER_SERVER"] + del environ["DB_PORT"] + del environ["DB_NAME"] + del environ["DB_SCHEMA"] + del environ["DB_READ_AND_WRITE_USER_NAME"] + del environ["DB_WRITER_SECRET_KEY"] + + +@patch(f"{FILE_PATH}.connect") +def test_connection_to_db(mock_connect: MagicMock) -> None: + # Act + connection_to_db( + server=DB_WRITER_SERVER, + port=DB_PORT, + db_name=DB_NAME, + db_schema=DB_SCHEMA, + db_user=DB_USER, + db_password=DB_PASSWORD, + ) + # Assert + mock_connect.assert_called_with( + host=DB_WRITER_SERVER, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + connect_timeout=2, + options=f"-c search_path=dbo,{DB_SCHEMA}", + application_name="DOS INTEGRATION ", + ) + + +def test_query_dos_db() -> None: + # Arrange + query = "SELECT * FROM my_table" + connection = MagicMock() # Act - disconnect_dos_db() + result = query_dos_db(connection, query) # Assert - mock_db_connection.close.assert_called() + assert result == connection.cursor.return_value + connection.cursor.assert_called_once_with(row_factory=dict_row) + connection.cursor.return_value.execute.assert_called_once_with(query=query, params=None) diff --git a/application/common/tests/test_dos_location.py b/application/common/tests/test_dos_location.py new file mode 100644 index 000000000..57e570c2f --- /dev/null +++ b/application/common/tests/test_dos_location.py @@ -0,0 +1,127 @@ +import pytest + +from application.common.dos_location import DoSLocation +from application.conftest import dummy_dos_location + + +@pytest.mark.parametrize( + ("dos_location", "expected_result"), + [ + ( + DoSLocation( + id=1, + postcode="TE57ER", + easting=None, + northing=None, + postaltown="TOWN", + latitude=None, + longitude=None, + ), + False, + ), + ( + DoSLocation( + id=1, + postcode="TE57ER", + easting=None, + northing=1, + postaltown="TOWN", + latitude=1.1, + longitude=1.1, + ), + False, + ), + ( + DoSLocation( + id=1, + postcode="TE57ER", + easting=1, + northing=None, + postaltown="TOWN", + latitude=1.1, + longitude=1.1, + ), + False, + ), + ( + DoSLocation( + id=1, + postcode="TE57ER", + easting=1, + northing=1, + postaltown="TOWN", + latitude=None, + longitude=1.1, + ), + False, + ), + ( + DoSLocation( + id=1, + postcode="TE57ER", + easting=1, + northing=1, + postaltown="TOWN", + latitude=1.1, + longitude=None, + ), + False, + ), + ( + DoSLocation( + id=1, + postcode="TE57ER", + easting=None, + northing=None, + postaltown="TOWN", + latitude=1.1, + longitude=1.1, + ), + False, + ), + ( + DoSLocation( + id=1, + postcode="TE57ER", + easting=1, + northing=1, + postaltown="TOWN", + latitude=None, + longitude=None, + ), + False, + ), + ( + DoSLocation(id=1, postcode="TE57ER", easting=1, northing=1, postaltown="TOWN", latitude=1.1, longitude=1.1), + True, + ), + ], +) +def test_doslocation_is_valid(dos_location: DoSLocation, expected_result: bool) -> None: + actual_result = dos_location.is_valid() + assert ( + actual_result is expected_result + ), f"is_valued check on {dos_location} was found to be {actual_result}, it should be {expected_result}." + + +@pytest.mark.parametrize( + ("input_postcode", "expected_result"), + [ + ("TE57ER", "TE57ER"), + ("TE5 7ER", "TE57ER"), + ("T E57ER", "TE57ER"), + ("T E57E R", "TE57ER"), + ("T E 5 7 E R", "TE57ER"), + ("TE57ER ", "TE57ER"), + (" TE57ER", "TE57ER"), + ("te5 7er", "TE57ER"), + ("te5 7 e r", "TE57ER"), + ], +) +def test_doslocation_normal_postcode(input_postcode: str, expected_result: str) -> None: + dos_location = dummy_dos_location() + dos_location.postcode = input_postcode + actual_output = dos_location.normal_postcode() + assert ( + actual_output == expected_result + ), f"Normalised postcode for '{input_postcode}' is '{actual_output}', it should be '{expected_result}'." diff --git a/application/common/tests/test_dynamodb.py b/application/common/tests/test_dynamodb.py index 6aae687fc..1cae4ed4a 100644 --- a/application/common/tests/test_dynamodb.py +++ b/application/common/tests/test_dynamodb.py @@ -1,90 +1,26 @@ -from pytest import fixture, raises -from os import environ -from json import dumps, loads from decimal import Decimal -from common.dynamodb import ( - add_change_request_to_dynamodb, - put_circuit_is_open, - get_circuit_is_open, - get_latest_sequence_id_for_a_given_odscode_from_dynamodb, - dict_hash, - TTL, -) -from boto3.dynamodb.types import TypeDeserializer +from json import dumps, loads +from os import environ from time import time -from aws_lambda_powertools import Logger -from unittest.mock import patch - - -@fixture -def dynamodb_table_create(dynamodb_client): - """Create a DynamoDB CHANGE_EVENTS_TABLE table fixture.""" - table = dynamodb_client.create_table( - TableName=environ["CHANGE_EVENTS_TABLE_NAME"], - BillingMode="PAY_PER_REQUEST", - KeySchema=[ - {"AttributeName": "Id", "KeyType": "HASH"}, - {"AttributeName": "ODSCode", "KeyType": "RANGE"}, - ], - AttributeDefinitions=[ - {"AttributeName": "Id", "AttributeType": "S"}, - {"AttributeName": "ODSCode", "AttributeType": "S"}, - {"AttributeName": "SequenceNumber", "AttributeType": "N"}, - ], - GlobalSecondaryIndexes=[ - { - "IndexName": "gsi_ods_sequence", - "KeySchema": [ - {"AttributeName": "ODSCode", "KeyType": "HASH"}, - {"AttributeName": "SequenceNumber", "KeyType": "RANGE"}, - ], - "Projection": {"ProjectionType": "ALL"}, - } - ], - ) - return table - - -def test_get_circuit_is_open_none(dynamodb_table_create, dynamodb_client): - - is_open = get_circuit_is_open("BLABLABLA") - - assert is_open is None - - -def test_put_and_get_circuit_is_open(dynamodb_table_create, dynamodb_client): - - put_circuit_is_open("TESTCIRCUIT", True) - is_open = get_circuit_is_open("TESTCIRCUIT") - - assert is_open - - -def test_put_circuit_exception(dynamodb_table_create, dynamodb_client): - temp_table = environ["CHANGE_EVENTS_TABLE_NAME"] - del environ["CHANGE_EVENTS_TABLE_NAME"] - with raises(Exception): - put_circuit_is_open("TESTCIRCUIT", True) - - environ["CHANGE_EVENTS_TABLE_NAME"] = temp_table +from unittest.mock import MagicMock, patch +from aws_lambda_powertools.logging import Logger +from boto3.dynamodb.types import TypeDeserializer -def test_get_circuit_exception(dynamodb_table_create, dynamodb_client): - temp_table = environ["CHANGE_EVENTS_TABLE_NAME"] - del environ["CHANGE_EVENTS_TABLE_NAME"] - with raises(Exception): - get_circuit_is_open("TESTCIRCUIT") +FILE_PATH = "application.common.dynamodb" - environ["CHANGE_EVENTS_TABLE_NAME"] = temp_table +def test_add_change_event_to_dynamodb( + dynamodb_table_create: dict[str, str], change_event: dict[str, str], dynamodb_client: object +) -> None: + from application.common.dynamodb import TTL, add_change_event_to_dynamodb, dict_hash -def test_add_change_request_to_dynamodb(dynamodb_table_create, change_event, dynamodb_client): # Arrange event_received_time = int(time()) # Act sequence_number = 1 change_id = dict_hash(change_event, sequence_number) - response_id = add_change_request_to_dynamodb(change_event.copy(), sequence_number, event_received_time) + response_id = add_change_event_to_dynamodb(change_event.copy(), sequence_number, event_received_time) item = dynamodb_client.get_item( TableName=environ["CHANGE_EVENTS_TABLE_NAME"], @@ -95,7 +31,7 @@ def test_add_change_request_to_dynamodb(dynamodb_table_create, change_event, dyn expected = loads(dumps(change_event), parse_float=Decimal) assert response_id == change_id - assert deserialized["EventReceived"] == int(event_received_time) + assert deserialized["EventReceived"] == event_received_time assert deserialized["TTL"] == int(event_received_time + TTL) assert deserialized["Id"] == change_id assert deserialized["SequenceNumber"] == 1 @@ -103,14 +39,19 @@ def test_add_change_request_to_dynamodb(dynamodb_table_create, change_event, dyn def test_get_latest_sequence_id_for_same_change_event_from_dynamodb( - dynamodb_table_create, change_event, dynamodb_client -): + dynamodb_table_create: dict[str, str], change_event: dict[str, str], dynamodb_client: object +) -> None: + from application.common.dynamodb import ( + add_change_event_to_dynamodb, + get_latest_sequence_id_for_a_given_odscode_from_dynamodb, + ) + event_received_time = int(time()) - add_change_request_to_dynamodb(change_event.copy(), 1, event_received_time) - add_change_request_to_dynamodb(change_event.copy(), 2, event_received_time) - add_change_request_to_dynamodb(change_event.copy(), 20, event_received_time) - add_change_request_to_dynamodb(change_event.copy(), 3, event_received_time) - add_change_request_to_dynamodb(change_event.copy(), 4, event_received_time) + add_change_event_to_dynamodb(change_event.copy(), 20, event_received_time) + add_change_event_to_dynamodb(change_event.copy(), 1, event_received_time) + add_change_event_to_dynamodb(change_event.copy(), 2, event_received_time) + add_change_event_to_dynamodb(change_event.copy(), 3, event_received_time) + add_change_event_to_dynamodb(change_event.copy(), 4, event_received_time) resp = dynamodb_client.query( TableName=environ["CHANGE_EVENTS_TABLE_NAME"], @@ -126,12 +67,19 @@ def test_get_latest_sequence_id_for_same_change_event_from_dynamodb( assert latest_sequence_number == 20 -def test_same_sequence_id_and_same_change_event_multiple_times(dynamodb_table_create, change_event, dynamodb_client): +def test_same_sequence_id_and_same_change_event_multiple_times( + dynamodb_table_create: dict[str, str], change_event: dict[str, str], dynamodb_client: object +) -> None: + from application.common.dynamodb import ( + add_change_event_to_dynamodb, + get_latest_sequence_id_for_a_given_odscode_from_dynamodb, + ) + event_received_time = int(time()) - add_change_request_to_dynamodb(change_event.copy(), 3, event_received_time) - add_change_request_to_dynamodb(change_event.copy(), 3, event_received_time) - add_change_request_to_dynamodb(change_event.copy(), 3, event_received_time) - add_change_request_to_dynamodb(change_event.copy(), 3, event_received_time) + add_change_event_to_dynamodb(change_event.copy(), 3, event_received_time) + add_change_event_to_dynamodb(change_event.copy(), 3, event_received_time) + add_change_event_to_dynamodb(change_event.copy(), 3, event_received_time) + add_change_event_to_dynamodb(change_event.copy(), 3, event_received_time) resp = dynamodb_client.query( TableName=environ["CHANGE_EVENTS_TABLE_NAME"], IndexName="gsi_ods_sequence", @@ -146,23 +94,33 @@ def test_same_sequence_id_and_same_change_event_multiple_times(dynamodb_table_cr assert latest_sequence_number == 3 -def test_no_records_in_db_for_a_given_odscode(dynamodb_table_create, change_event): +def test_no_records_in_db_for_a_given_odscode(dynamodb_table_create: object, change_event: dict[str, str]) -> None: + from application.common.dynamodb import get_latest_sequence_id_for_a_given_odscode_from_dynamodb + latest_sequence_number = get_latest_sequence_id_for_a_given_odscode_from_dynamodb(change_event["ODSCode"]) assert latest_sequence_number == 0 @patch.object(Logger, "error") def test_get_latest_sequence_id_for_different_change_event_from_dynamodb( - mock_logger, dynamodb_table_create, change_event, dynamodb_client -): + mock_logger: MagicMock, + dynamodb_table_create: object, + change_event: dict[str, str], + dynamodb_client: object, +) -> None: + from application.common.dynamodb import ( + add_change_event_to_dynamodb, + get_latest_sequence_id_for_a_given_odscode_from_dynamodb, + ) + event_received_time = int(time()) odscode = change_event["ODSCode"] - cevent = change_event.copy() - add_change_request_to_dynamodb(cevent, 1, event_received_time) - add_change_request_to_dynamodb(copy_and_modify_website(cevent, "www.test1.com"), 2, event_received_time) - add_change_request_to_dynamodb(copy_and_modify_website(cevent, "www.test2.com"), 3, event_received_time) - add_change_request_to_dynamodb(copy_and_modify_website(cevent, "www.test3.com"), 4, event_received_time) - add_change_request_to_dynamodb(copy_and_modify_website(cevent, "www.test4.com"), 44, event_received_time) + new_change_event = change_event.copy() + add_change_event_to_dynamodb(new_change_event, 44, event_received_time) + add_change_event_to_dynamodb(copy_and_modify_website(new_change_event, "www.test1.com"), 1, event_received_time) + add_change_event_to_dynamodb(copy_and_modify_website(new_change_event, "www.test2.com"), 2, event_received_time) + add_change_event_to_dynamodb(copy_and_modify_website(new_change_event, "www.test3.com"), 3, event_received_time) + add_change_event_to_dynamodb(copy_and_modify_website(new_change_event, "www.test4.com"), 4, event_received_time) resp = dynamodb_client.query( TableName=environ["CHANGE_EVENTS_TABLE_NAME"], IndexName="gsi_ods_sequence", @@ -223,7 +181,7 @@ def test_get_latest_sequence_id_for_different_change_event_from_dynamodb( assert latest_sequence_number == expected_latest_sequence_number -def copy_and_modify_website(ce, new_website: str): - copy = ce.copy() +def copy_and_modify_website(change_event: dict[str, str], new_website: str) -> None: + copy = change_event.copy() copy["Contacts"][0]["ContactValue"] = new_website return copy diff --git a/application/common/tests/test_errors.py b/application/common/tests/test_errors.py new file mode 100644 index 000000000..7f4656bb5 --- /dev/null +++ b/application/common/tests/test_errors.py @@ -0,0 +1,17 @@ +import pytest + +from application.common.errors import DynamoDBError, ValidationError + + +def test_validation_exception() -> None: + # Arrange & Act + with pytest.raises(ValidationError): # noqa: PT012 + msg = "Test" + raise ValidationError(msg) + + +def test_dynamodb_exception() -> None: + # Arrange & Act + with pytest.raises(DynamoDBError): # noqa: PT012 + msg = "Test" + raise DynamoDBError(msg) diff --git a/application/common/tests/test_middlewares.py b/application/common/tests/test_middlewares.py index dd38a9d4f..873ec8174 100644 --- a/application/common/tests/test_middlewares.py +++ b/application/common/tests/test_middlewares.py @@ -1,26 +1,144 @@ import logging -from pytest import raises +import re +from json import dumps +import pytest +from aws_lambda_powertools.utilities.data_classes import SQSEvent +from aws_lambda_powertools.utilities.typing import LambdaContext from botocore.exceptions import ClientError -from application.common.middlewares import unhandled_exception_logging +from application.common.middlewares import ( + redact_staff_key_from_event, + unhandled_exception_logging, + unhandled_exception_logging_hidden_event, +) +from application.common.utilities import extract_body +from application.conftest import PHARMACY_STANDARD_EVENT, PHARMACY_STANDARD_EVENT_STAFF -def test_unhandled_exception_logging(caplog): +def test_redact_staff_key_from_event_with_no_staff_key(caplog: pytest.LogCaptureFixture) -> None: + @redact_staff_key_from_event() + def dummy_handler(event: dict[str, str], context: LambdaContext) -> SQSEvent: + return event + + # Arrange + event = SQS_EVENT.copy() + event["Records"][0]["body"] = dumps(PHARMACY_STANDARD_EVENT.copy()) + assert "Staff" not in extract_body(event["Records"][0]["body"]) + # Act + result = dummy_handler(event, None) + assert "Checking if 'Staff' key needs removing from Change Event payload" in caplog.text + assert "Redacted 'Staff' key from Change Event payload" not in caplog.text + assert "Staff" not in extract_body(result["Records"][0]["body"]) + + +def test_redact_staff_key_from_event(caplog: pytest.LogCaptureFixture) -> None: + @redact_staff_key_from_event() + def dummy_handler(event: dict[str, str], context: LambdaContext) -> SQSEvent: + return event + + # Arrange + event = SQS_EVENT.copy() + event["Records"][0]["body"] = dumps(PHARMACY_STANDARD_EVENT_STAFF.copy()) + assert "Staff" in extract_body(event["Records"][0]["body"]) + # Act + result = dummy_handler(event, None) + assert "Checking if 'Staff' key needs removing from Change Event payload" in caplog.text + assert "Redacted 'Staff' key from Change Event payload" in caplog.text + assert "Staff" not in extract_body(result["Records"][0]["body"]) + + +def test_redact_staff_key_from_event_no_records(caplog: pytest.LogCaptureFixture) -> None: + @redact_staff_key_from_event() + def dummy_handler(event: dict[str, str], context: LambdaContext) -> SQSEvent: + return event + + # Arrange + event = SQS_EVENT.copy() + event["Records"] = [] + # Act + result = dummy_handler(event, None) + assert "Checking if 'Staff' key needs removing from Change Event payload" in caplog.text + assert "Redacted 'Staff' key from Change Event payload" not in caplog.text + assert len(result["Records"]) == 0 + + +def test_unhandled_exception_logging(caplog: pytest.LogCaptureFixture) -> None: @unhandled_exception_logging - def client_error_func(event, context): + def client_error_func(event: dict[str, str], context: LambdaContext) -> None: raise ClientError({"Error": {"Code": "dummy_error", "Message": "dummy_message"}}, "op_name") @unhandled_exception_logging - def regular_error_func(event, context): - raise Exception("dummy exception message") + def regular_error_func(event: dict[str, str], context: LambdaContext) -> None: + msg = "dummy exception message" + raise Exception(msg) # noqa: TRY002 with caplog.at_level(logging.ERROR): - - with raises(ClientError): + with pytest.raises( + ClientError, + match=re.escape("An error occurred (dummy_error) when calling the op_name operation: dummy_message"), + ): client_error_func(None, None) - assert "Boto3 Client Error - 'dummy_error': dummy_message" in caplog.text - with raises(Exception): + with pytest.raises(Exception, match="dummy exception message"): regular_error_func(None, None) - assert "dummy_error" in caplog.text + + +def test_unhandled_exception_logging_no_error() -> None: + @unhandled_exception_logging + def dummy_handler(event: dict[str, str], context: LambdaContext) -> None: + pass + + # Arrange + event = SQS_EVENT.copy() + + # Act + dummy_handler(event, None) + + +def test_unhandled_exception_logging_hidden_event(caplog: pytest.LogCaptureFixture) -> None: + @unhandled_exception_logging_hidden_event + def regular_error_func(event: dict[str, str], context: LambdaContext) -> None: + msg = "dummy exception message" + raise Exception(msg) # noqa: TRY002 + + with caplog.at_level(logging.ERROR): + with pytest.raises(Exception, match="dummy exception message"): + regular_error_func(None, None) + assert "dummy_error" not in caplog.text + + +def test_unhandled_exception_logging_hidden_event_no_error() -> None: + @unhandled_exception_logging_hidden_event + def dummy_handler(event: dict[str, str], context: LambdaContext) -> None: + pass + + # Arrange + event = SQSEvent(None) + # Act + dummy_handler(event, None) + + +SQS_EVENT = { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": None, + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1642619743522", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185", + }, + "messageAttributes": { + "correlation-id": {"stringValue": "1", "dataType": "String"}, + "sequence-number": {"stringValue": "1", "dataType": "Number"}, + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2", + }, + ], +} diff --git a/application/event_processor/tests/test_nhs.py b/application/common/tests/test_nhs.py similarity index 77% rename from application/event_processor/tests/test_nhs.py rename to application/common/tests/test_nhs.py index 58deda90c..834e796bd 100644 --- a/application/event_processor/tests/test_nhs.py +++ b/application/common/tests/test_nhs.py @@ -1,14 +1,24 @@ +from datetime import date, time + import pytest -from datetime import time, date -from .conftest import PHARMACY_STANDARD_EVENT -from ..nhs import NHSEntity, is_std_opening_json, is_spec_opening_json +from application.common.constants import CLOSED_AND_HIDDEN_STATUSES +from application.common.nhs import ( + NHSEntity, + get_palliative_care_log_value, + is_spec_opening_json, + is_std_opening_json, + skip_if_key_is_none, +) +from application.conftest import PHARMACY_STANDARD_EVENT, dummy_dos_service +from common.constants import PHARMACY_SERVICE_TYPE_IDS from common.opening_times import OpenPeriod, SpecifiedOpeningTime, StandardOpeningTimes test_attr_names = ("odscode", "website", "PublicPhone", "Phone", "Postcode") +PHARMACY_SERVICE_ID = PHARMACY_SERVICE_TYPE_IDS[0] -def test__init__(): +def test__init__() -> None: # Arrange test_data = PHARMACY_STANDARD_EVENT # Act @@ -30,7 +40,7 @@ def test__init__(): ] -def test_get_specified_opening_times(): +def test_get_specified_opening_times() -> None: # Arrange nhs_entity = NHSEntity( { @@ -91,8 +101,8 @@ def test_get_specified_opening_times(): "AdditionalOpeningDate": "Jan 20 2023", "IsOpen": False, }, - ] - } + ], + }, ) # Act # Assert @@ -113,11 +123,11 @@ def test_get_specified_opening_times(): ), f"NHS entity should contain {exp_spec_open_time} but can't be found in list {actual_spec_open_times}" assert len(actual_spec_open_times) == len( - expected + expected, ), f"Should return {len(expected)} , actually: {len(actual_spec_open_times)}" -def test_get_standard_opening_times(): +def test_get_standard_opening_times() -> None: # Arrange nhs_entity = NHSEntity( { @@ -162,8 +172,8 @@ def test_get_standard_opening_times(): "AdditionalOpeningDate": "", "IsOpen": False, }, - ] - } + ], + }, ) # Act expected_std_open_times = StandardOpeningTimes() @@ -180,7 +190,7 @@ def test_get_standard_opening_times(): @pytest.mark.parametrize("organisation_status", ["Visible", "OTHER"]) -def test_is_status_hidden_or_closed_open_service(organisation_status: str): +def test_is_status_hidden_or_closed_open_service(organisation_status: str) -> None: # Arrange test_data = {"OrganisationStatus": organisation_status} nhs_entity = NHSEntity(test_data) @@ -190,8 +200,8 @@ def test_is_status_hidden_or_closed_open_service(organisation_status: str): assert not result -@pytest.mark.parametrize("organisation_status", NHSEntity.CLOSED_AND_HIDDEN_STATUSES) -def test_is_status_hidden_or_closed_not_open_service(organisation_status: str): +@pytest.mark.parametrize("organisation_status", CLOSED_AND_HIDDEN_STATUSES) +def test_is_status_hidden_or_closed_not_open_service(organisation_status: str) -> None: # Arrange test_data = {"OrganisationStatus": organisation_status} nhs_entity = NHSEntity(test_data) @@ -202,7 +212,7 @@ def test_is_status_hidden_or_closed_not_open_service(organisation_status: str): @pytest.mark.parametrize( - "open_time_json, expected", + ("open_time_json", "expected"), [ ({}, False), ( @@ -363,13 +373,13 @@ def test_is_status_hidden_or_closed_not_open_service(organisation_status: str): ), ], ) -def test_is_std_opening_json(open_time_json, expected): +def test_is_std_opening_json(open_time_json: dict, expected: bool) -> None: actual = is_std_opening_json(open_time_json) assert actual == expected, f"Std time should be valid={expected} but wasn't. open_time={open_time_json}" @pytest.mark.parametrize( - "open_time_json, expected", + ("open_time_json", "expected"), [ ({}, False), ( @@ -442,17 +452,6 @@ def test_is_std_opening_json(open_time_json, expected): }, False, ), - ( - { - "Weekday": "", - "OpeningTime": "10:00", - "ClosingTime": "17:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "Apr 14 2021", - "IsOpen": True, - }, - False, - ), ( { "Weekday": "", @@ -543,6 +542,125 @@ def test_is_std_opening_json(open_time_json, expected): ), ], ) -def test_is_spec_opening_json(open_time_json, expected): +def test_is_spec_opening_json(open_time_json: dict, expected: bool) -> None: actual = is_spec_opening_json(open_time_json) assert actual == expected, f"Spec time should be valid={expected} but wasn't. open_time={open_time_json}" + + +def test_is_matching_dos_service() -> None: + nhs_entity = NHSEntity({}) + dos_service = dummy_dos_service() + + dos_service.typeid = PHARMACY_SERVICE_TYPE_IDS[0] + nhs_entity.odscode = None + dos_service.odscode = None + assert nhs_entity.is_matching_dos_service(dos_service) is False + + nhs_entity.odscode = "ABCDE" + dos_service.odscode = None + assert nhs_entity.is_matching_dos_service(dos_service) is False + + nhs_entity.odscode = None + dos_service.odscode = "ABCDEFGHI" + assert nhs_entity.is_matching_dos_service(dos_service) is False + + nhs_entity.odscode = "ABCDE" + dos_service.odscode = "ABCDEFGHI" + assert nhs_entity.is_matching_dos_service(dos_service) + + nhs_entity.odscode = "" + dos_service.odscode = "ABCDEFGHI" + assert nhs_entity.is_matching_dos_service(dos_service) is False + + nhs_entity.odscode = "ABCDE" + dos_service.odscode = "" + assert nhs_entity.is_matching_dos_service(dos_service) is False + + nhs_entity.odscode = "ABCDE" + dos_service.odscode = "1ABCDEFGHI" + assert nhs_entity.is_matching_dos_service(dos_service) is False + + nhs_entity.odscode = "1ABCDE" + dos_service.odscode = "1ABCDEFGHI" + assert nhs_entity.is_matching_dos_service(dos_service) + + nhs_entity.odscode = "VABCDU" + dos_service.odscode = "VABCDU123" + dos_service.typeid = 324634324 + assert nhs_entity.is_matching_dos_service(dos_service) is False + + +@pytest.mark.parametrize( + ("input_value", "output_value"), + [ + ("", None), + (None, None), + ([], False), + ({}, None), + ( + [ + { + "ServiceName": "Pharmacy palliative care medication stockholder", + "ServiceDescription": None, + "ServiceCode": "SRV0559", + }, + ], + True, + ), + ], +) +def test_check_for_uec_service(input_value: str | bool | None, output_value: bool | dict) -> None: + entity = NHSEntity({"ODSCode": "V012345", "UecServices": input_value}) + assert entity.check_for_uec_service("SRV0559") == output_value + + +@pytest.mark.parametrize( + ("input_value", "output_value"), + [ + ("", None), + (None, None), + ([], False), + ({}, None), + ( + [ + { + "ServiceName": "Pharmacy palliative care medication stockholder", + "ServiceDescription": None, + "ServiceCode": "SRV0559", + }, + ], + True, + ), + ], +) +def test_check_for_service(input_value: str | bool | None, output_value: bool | dict) -> None: + entity = NHSEntity({"ODSCode": "V012345", "Services": input_value}) + assert entity.check_for_service("SRV0559") == output_value + + +@pytest.mark.parametrize( + ("input_value", "output_value"), + [ + (None, True), + ("", False), + ("V012345", False), + (False, False), + ], +) +def test_skip_if_key_is_none(input_value: str | bool | None, output_value: bool) -> None: + assert output_value == skip_if_key_is_none(input_value) + + +@pytest.mark.parametrize( + ("palliative_care", "skip_palliative_care", "output_value"), + [ + (True, False, True), + (False, False, False), + (True, True, "Never been updated on Profile Manager, skipped palliative care checks"), + (False, True, "Never been updated on Profile Manager, skipped palliative care checks"), + ], +) +def test_get_palliative_care_log_value( + palliative_care: bool, skip_palliative_care: bool, output_value: bool | str +) -> None: + assert get_palliative_care_log_value(palliative_care, skip_palliative_care) == output_value diff --git a/application/common/tests/test_opening_times.py b/application/common/tests/test_opening_times.py index b729b760b..8848fa7f7 100644 --- a/application/common/tests/test_opening_times.py +++ b/application/common/tests/test_opening_times.py @@ -1,17 +1,33 @@ +from datetime import date, datetime, time, timedelta + import pytest -from datetime import datetime, date, time, timedelta -from ..opening_times import OpenPeriod, SpecifiedOpeningTime, StandardOpeningTimes +from application.common.opening_times import ( + WEEKDAYS, + OpenPeriod, + SpecifiedOpeningTime, + StandardOpeningTimes, + opening_period_times_from_list, +) + + +def test_open_period_repr() -> None: + # Arrange + open_period = OpenPeriod(time(8, 0), time(12, 0)) + # Act + value = repr(open_period) + # Assert + assert value == "OpenPeriod(08:00:00-12:00:00)" @pytest.mark.parametrize( - "start, end, other_start, other_end, expected", + ("start", "end", "other_start", "other_end", "expected"), [ (time(8, 0), time(12, 0), time(8, 0), time(12, 0), True), (time(8, 0), time(12, 0), time(13, 0), time(23, 0), False), ], ) -def test_open_period_eq(start, end, other_start, other_end, expected): +def test_open_period_eq(start: time, end: time, other_start: time, other_end: time, expected: bool) -> None: # Arrange open_period = OpenPeriod(start, end) # Act @@ -20,8 +36,46 @@ def test_open_period_eq(start, end, other_start, other_end, expected): assert expected == actual, f"Should return {expected} , actually: {actual}" -@pytest.mark.parametrize("start, end, expected", [(time(8, 0), time(12, 0), True), (time(12, 0), time(8, 0), False)]) -def test_open_period_start_before_end(start, end, expected): +def test_open_period_eq_hash() -> None: + a = OpenPeriod.from_string_times("9:00", "17:00") + a2 = OpenPeriod.from_string_times("9:00:00", "17:00:00") + b = OpenPeriod.from_string_times("09:00", "16:00") + b2 = OpenPeriod.from_string_times("9:00", "16:00") + c = OpenPeriod.from_string_times("02:00", "16:00:00") + d = OpenPeriod.from_string_times("09:00", "17:00:01") + + assert a == a2 + assert hash(a) == hash(a2) + + assert a != b + assert hash(a) != hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert b == b2 + assert hash(b) == hash(b2) + + assert b != c + assert hash(b) != hash(c) + + assert c != d + assert hash(c) != hash(d) + + b.end = time(17, 0, 0) + assert a == b + assert hash(a) == hash(b) + + a.start = time(3, 0, 0) + assert a != a2 + assert hash(a) != hash(a2) + + +@pytest.mark.parametrize( + ("start", "end", "expected"), + [(time(8, 0), time(12, 0), True), (time(12, 0), time(8, 0), False)], +) +def test_open_period_start_before_end(start: time, end: time, expected: bool) -> None: # Arrange open_period = OpenPeriod(start, end) # Act @@ -30,7 +84,29 @@ def test_open_period_start_before_end(start, end, expected): assert expected == actual, f"Should return {expected} , actually: {actual}" -def test_openperiod_any_overlaps(): +@pytest.mark.parametrize( + ("first_start", "first_end", "second_start", "second_end", "expected"), + [ + (time(8, 0), time(12, 0), time(13, 0), time(17, 0), False), + (time(8, 0), time(12, 0), time(12, 1), time(17, 0), False), + (time(8, 0), time(12, 0), time(12, 0, 1), time(17, 0), False), + (time(13, 0), time(17, 0), time(8, 0), time(12, 0), False), + (time(8, 0), time(12, 59), time(13, 0), time(15, 0), False), + (time(12, 40), time(15, 23), time(18, 3), time(22, 16), False), + (time(8, 0), time(12, 0), time(12, 0), time(17, 0), True), + (time(8, 0), time(12, 0), time(10, 0), time(17, 0), True), + (time(0, 0), time(23, 59), time(0, 0), time(23, 59), True), + (time(8, 0), time(12, 0), time(7, 0), time(17, 0), True), + (time(1, 23), time(12, 0), time(10, 0), time(17, 0), True), + ], +) +def test_openperiod_overlaps( + first_start: time, first_end: time, second_start: time, second_end: time, expected: bool +) -> None: + assert OpenPeriod(first_start, first_end).overlaps(OpenPeriod(second_start, second_end)) == expected + + +def test_openperiod_any_overlaps() -> None: open_periods = [ OpenPeriod(time(1, 0, 0), time(2, 0, 0)), OpenPeriod(time(3, 0, 0), time(5, 0, 0)), @@ -52,7 +128,7 @@ def test_openperiod_any_overlaps(): assert spec.any_overlaps() -def test_openperiod_all_start_before_end(): +def test_openperiod_all_start_before_end() -> None: open_periods = [ OpenPeriod(time(1, 0, 0), time(2, 0, 0)), OpenPeriod(time(3, 0, 0), time(5, 0, 0)), @@ -74,7 +150,7 @@ def test_openperiod_all_start_before_end(): assert spec.all_start_before_end() is False -def test_open_period_str(): +def test_open_period_str() -> None: assert str(OpenPeriod(time(8, 0, 0), time(15, 0, 0))) == "08:00:00-15:00:00" assert str(OpenPeriod(time(0, 0, 0), time(15, 0, 0))) == "00:00:00-15:00:00" assert str(OpenPeriod(time(8, 0, 0), time(23, 59, 59))) == "08:00:00-23:59:59" @@ -83,26 +159,7 @@ def test_open_period_str(): assert str(OpenPeriod(time(13, 35, 23), time(13, 35, 24))) == "13:35:23-13:35:24" -def test_open_period_export_cr_format(): - assert OpenPeriod(time(8, 0, 0), time(15, 0, 0)).export_cr_format() == {"start_time": "08:00", "end_time": "15:00"} - assert OpenPeriod(time(0, 0, 0), time(15, 0, 0)).export_cr_format() == {"start_time": "00:00", "end_time": "15:00"} - assert OpenPeriod(time(8, 0, 0), time(23, 59, 0)).export_cr_format() == {"start_time": "08:00", "end_time": "23:59"} - assert OpenPeriod(time(8, 0, 0), time(23, 59, 59)).export_cr_format() == { - "start_time": "08:00", - "end_time": "23:59", - } - assert OpenPeriod(time(0, 0, 0), time(23, 59, 59)).export_cr_format() == { - "start_time": "00:00", - "end_time": "23:59", - } - assert OpenPeriod(time(1, 2, 3), time(4, 5, 0)).export_cr_format() == {"start_time": "01:02", "end_time": "04:05"} - assert OpenPeriod(time(13, 35, 0), time(13, 36, 0)).export_cr_format() == { - "start_time": "13:35", - "end_time": "13:36", - } - - -def test_openperiod_list_string(): +def test_openperiod_list_string() -> None: a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) b = OpenPeriod(time(13, 0, 0), time(17, 30, 0)) c = OpenPeriod(time(19, 0, 0), time(23, 59, 59)) @@ -114,7 +171,7 @@ def test_openperiod_list_string(): assert OpenPeriod.list_string([c, b, a]) == "[08:00:00-12:00:00, 13:00:00-17:30:00, 19:00:00-23:59:59]" -def test_openperiod_equal_lists(): +def test_openperiod_equal_lists() -> None: a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) b = OpenPeriod(time(13, 0, 0), time(17, 30, 0)) c = OpenPeriod(time(19, 0, 0), time(23, 59, 59)) @@ -128,8 +185,15 @@ def test_openperiod_equal_lists(): assert OpenPeriod.equal_lists([a, b, c], [c, b, a]) assert OpenPeriod.equal_lists([a2, c, c], [c, a, c]) + assert not OpenPeriod.equal_lists([a], [b]) + assert not OpenPeriod.equal_lists([a, b, c], [a, b]) + assert not OpenPeriod.equal_lists([c, c], [a, c]) + assert not OpenPeriod.equal_lists([b, c], []) + assert not OpenPeriod.equal_lists([a, b, c], [a2, b, a2]) + assert not OpenPeriod.equal_lists([a, b, c], [a]) + -def test_open_period__lt__gt__(): +def test_open_period__lt__gt__() -> None: a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) a2 = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) b = OpenPeriod(time(9, 0, 0), time(12, 0, 0)) @@ -154,7 +218,6 @@ def test_open_period__lt__gt__(): @pytest.mark.parametrize( "opening_period_2", [ - OpenPeriod(time(8, 0, 0), time(12, 0, 0)), OpenPeriod(time(8, 0, 0), time(12, 0, 0)), OpenPeriod(datetime(1970, 1, 1, 8, 0, 0).time(), time(12, 0, 0)), OpenPeriod(datetime.strptime("8:00", "%H:%M").time(), time(12, 0, 0)), @@ -163,46 +226,16 @@ def test_open_period__lt__gt__(): OpenPeriod((datetime(2000, 1, 1, 7, 0, 0) + timedelta(hours=1)).time(), time(12, 0, 0)), ], ) -def test_open_period_hash(opening_period_2: OpenPeriod): +def test_open_period_hash(opening_period_2: OpenPeriod) -> None: open_period_1 = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) assert open_period_1 == opening_period_2, f"{open_period_1} not found to be equal to {opening_period_2}" assert hash(open_period_1) == hash( - opening_period_2 + opening_period_2, ), f"hash {hash(open_period_1)} not found to be equal to {hash(opening_period_2)}" -def test_openperiod_from_string(): - a = OpenPeriod.from_string("08:34-15:13") - assert a.start == time(8, 34, 0) - assert a.end == time(15, 13, 0) - - b = OpenPeriod.from_string("04:45:55-09:32:22") - assert b.start == time(4, 45, 55) - assert b.end == time(9, 32, 22) - - c = OpenPeriod.from_string("00:00:00-09:32:22") - assert c.start == time(0, 0, 0) - assert c.end == time(9, 32, 22) - - d = OpenPeriod.from_string("00:00-23:59") - assert d.start == time(0, 0, 0) - assert d.end == time(23, 59, 00) - - assert OpenPeriod.from_string("") is None - assert OpenPeriod.from_string("hello") is None - assert OpenPeriod.from_string("12:0015:32") is None - assert OpenPeriod.from_string("12:00 15:32") is None - assert OpenPeriod.from_string("12:00") is None - assert OpenPeriod.from_string("08:00-24:00") is None - assert OpenPeriod.from_string("38:00-12:00") is None - assert OpenPeriod.from_string("08:00-44:00") is None - assert OpenPeriod.from_string(231892) is None - assert OpenPeriod.from_string(None) is None - assert OpenPeriod.from_string(2.38) is None - - -def test_openperiod_from_string_times(): +def test_openperiod_from_string_times() -> None: a = OpenPeriod.from_string_times("08:34", "15:13") assert a.start == time(8, 34, 0) assert a.end == time(15, 13, 0) @@ -239,7 +272,35 @@ def test_openperiod_from_string_times(): assert OpenPeriod.from_string_times(2.38, "03:00") is None -def test_specifiedopeningtime_eq_and_hash(): +def test_open_period_export_test_format() -> None: + assert OpenPeriod(time(8, 0, 0), time(15, 0, 0)).export_test_format() == { + "start_time": "08:00", + "end_time": "15:00", + } + assert OpenPeriod(time(0, 0, 0), time(15, 0, 0)).export_test_format() == { + "start_time": "00:00", + "end_time": "15:00", + } + assert OpenPeriod(time(8, 0, 0), time(23, 59, 0)).export_test_format() == { + "start_time": "08:00", + "end_time": "23:59", + } + assert OpenPeriod(time(8, 0, 0), time(23, 59, 59)).export_test_format() == { + "start_time": "08:00", + "end_time": "23:59", + } + assert OpenPeriod(time(0, 0, 0), time(23, 59, 59)).export_test_format() == { + "start_time": "00:00", + "end_time": "23:59", + } + assert OpenPeriod(time(1, 2, 3), time(4, 5, 0)).export_test_format() == {"start_time": "01:02", "end_time": "04:05"} + assert OpenPeriod(time(13, 35, 0), time(13, 36, 0)).export_test_format() == { + "start_time": "13:35", + "end_time": "13:36", + } + + +def test_specifiedopeningtime_eq_and_hash() -> None: op1 = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) op2 = OpenPeriod(time(13, 0, 0), time(17, 30, 0)) op3 = OpenPeriod(time(19, 0, 0), time(23, 30, 0)) @@ -254,6 +315,7 @@ def test_specifiedopeningtime_eq_and_hash(): sp2b = SpecifiedOpeningTime([op3, op2, op1], d1) sp3 = SpecifiedOpeningTime([op2, op3], d2) sp3b = SpecifiedOpeningTime([op3, op2], d2b) + sp4 = SpecifiedOpeningTime([op1], d2) assert sp1 == sp1b assert hash(sp1) == hash(sp1b) @@ -268,53 +330,19 @@ def test_specifiedopeningtime_eq_and_hash(): assert hash(sp2) != hash(sp3) assert sp1 != sp3 assert hash(sp1) != hash(sp3) + assert sp3 != sp4 + assert hash(sp3) != hash(sp4) -@pytest.mark.parametrize( - "expected, actual", - [ - ({"2021-12-25": []}, SpecifiedOpeningTime([], date(2021, 12, 25))), - ( - {"2021-03-02": [{"start_time": "08:00", "end_time": "17:00"}]}, - SpecifiedOpeningTime([OpenPeriod(time(8, 0, 0), time(17, 0, 0))], date(2021, 3, 2)), - ), - ( - { - "2039-12-30": [ - {"start_time": "02:00", "end_time": "09:30"}, - {"start_time": "11:45", "end_time": "18:00"}, - ] - }, - SpecifiedOpeningTime( - [OpenPeriod(time(2, 0, 0), time(9, 30, 0)), OpenPeriod(time(11, 45, 0), time(18, 0, 0))], - date(2039, 12, 30), - ), - ), - ( - { - "2060-06-01": [ - {"start_time": "05:00", "end_time": "09:30"}, - {"start_time": "11:45", "end_time": "18:00"}, - {"start_time": "20:45", "end_time": "22:00"}, - ] - }, - SpecifiedOpeningTime( - [ - OpenPeriod(time(5, 0, 0), time(9, 30, 0)), - OpenPeriod(time(20, 45, 0), time(22, 0, 0)), - OpenPeriod(time(11, 45, 0), time(18, 0, 0)), - ], - date(2060, 6, 1), - ), - ), - ], -) -def test_specified_opening_time_export_cr_format(expected: dict, actual: SpecifiedOpeningTime): - cr_format = actual.export_cr_format() - assert cr_format == expected, f"expected {expected} SpecifiedOpeningTime change req format but got {cr_format}" +def test_specifiedopeningtime_open_string() -> None: + s = SpecifiedOpeningTime([], date(2020, 5, 5), is_open=True) + assert s.open_string() == "OPEN" + s.is_open = False + assert s.open_string() == "CLOSED" -def test_specifiedopentime_contradiction(): + +def test_specifiedopentime_contradiction() -> None: spec = SpecifiedOpeningTime([], date(2021, 12, 24), is_open=False) op = OpenPeriod(time(8, 0, 0), time(17, 0, 0)) @@ -330,7 +358,7 @@ def test_specifiedopentime_contradiction(): assert spec.contradiction() -def test_specifiedopentimes_is_valid(): +def test_specifiedopentimes_is_valid() -> None: a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) b = OpenPeriod(time(13, 0, 0), time(17, 30, 0)) c = OpenPeriod(time(19, 0, 0), time(23, 59, 59)) @@ -376,7 +404,7 @@ def test_specifiedopentimes_is_valid(): assert sp1.is_valid() is False -def test_specifiedopentimes_equal_lists(): +def test_specifiedopentimes_equal_lists() -> None: a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) b = OpenPeriod(time(13, 0, 0), time(17, 30, 0)) c = OpenPeriod(time(19, 0, 0), time(23, 30, 0)) @@ -385,6 +413,7 @@ def test_specifiedopentimes_equal_lists(): sp2 = SpecifiedOpeningTime([a, b, c], date(2021, 12, 24)) sp2b = SpecifiedOpeningTime([a, b, c], date(2021, 12, 24)) sp3 = SpecifiedOpeningTime([b], date(2021, 12, 24)) + sp4 = SpecifiedOpeningTime([c], date(2021, 12, 24)) assert sp1 != sp2 assert sp1 != sp3 @@ -406,46 +435,116 @@ def test_specifiedopentimes_equal_lists(): assert not SpecifiedOpeningTime.equal_lists([sp3], []) assert not SpecifiedOpeningTime.equal_lists([sp1, sp2, sp3], [sp1, sp1, sp3]) assert not SpecifiedOpeningTime.equal_lists([sp1, sp2, sp3, sp3], [sp1, sp2, sp3]) + assert not SpecifiedOpeningTime.equal_lists([sp3], [sp4]) + assert not SpecifiedOpeningTime.equal_lists([sp1, sp3], [sp1, sp4]) -def test_standard_opening_times_export_cr_format(): +def test_specifiedopentimes_remove_past_dates() -> None: + a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) + b = OpenPeriod(time(13, 0, 0), time(17, 30, 0)) + c = OpenPeriod(time(19, 0, 0), time(23, 30, 0)) - # Start with empty - std_opening_times = StandardOpeningTimes() - expected = { - "Monday": [], - "Tuesday": [], - "Wednesday": [], - "Thursday": [], - "Friday": [], - "Saturday": [], - "Sunday": [], - } - assert std_opening_times.export_cr_format() == expected + now_date = datetime.now().date() - # Add single opening time for monday - std_opening_times.monday.append(OpenPeriod(time(8, 0, 0), time(15, 0, 0))) - expected["Monday"].append({"start_time": "08:00", "end_time": "15:00"}) - assert std_opening_times.export_cr_format() == expected + future1 = SpecifiedOpeningTime([], (now_date + timedelta(weeks=4)), is_open=False) + future2 = SpecifiedOpeningTime([a, b, c], (now_date + timedelta(weeks=5))) + past = SpecifiedOpeningTime([b], (now_date - timedelta(weeks=4))) - # Add another to tuesday - std_opening_times.tuesday.append(OpenPeriod(time(8, 0, 0), time(20, 0, 0))) - expected["Tuesday"].append({"start_time": "08:00", "end_time": "20:00"}) - assert std_opening_times.export_cr_format() == expected + assert SpecifiedOpeningTime.remove_past_dates(times_list=[future1, future2, past]) == [future1, future2] - # Add another to monday - std_opening_times.monday.append(OpenPeriod(time(16, 0, 0), time(20, 0, 0))) - expected["Monday"].append({"start_time": "16:00", "end_time": "20:00"}) - assert std_opening_times.export_cr_format() == expected - # Add to every other day - for day in ["Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]: - getattr(std_opening_times, day.lower()).append(OpenPeriod(time(16, 0, 0), time(20, 0, 0))) - expected[day].append({"start_time": "16:00", "end_time": "20:00"}) - assert std_opening_times.export_cr_format() == expected +def test_specifiedopentime_export_service_history_format_open() -> None: + # Arrange + a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) + b = OpenPeriod(time(13, 0, 0), time(17, 30, 0)) + c = OpenPeriod(time(19, 0, 0), time(23, 30, 0)) + specified_opening_time = SpecifiedOpeningTime([a, b, c], date(2021, 12, 24), is_open=True) + # Act + result = specified_opening_time.export_service_history_format() + # Assert + assert result == [ + "2021-12-24-28800-43200", + "2021-12-24-46800-63000", + "2021-12-24-68400-84600", + ] + + +def test_specifiedopentime_export_service_history_format_closed() -> None: + # Arrange + specified_opening_time = SpecifiedOpeningTime([], date(2021, 12, 24), is_open=False) + # Act + result = specified_opening_time.export_service_history_format() + # Assert + assert result == ["2021-12-24-closed"] + + +def test_specifiedopentime_export_dos_log_format_open() -> None: + # Arrange + specified_opening_time = SpecifiedOpeningTime( + [OpenPeriod(time(9, 0, 0), time(11, 0, 0))], + date(2021, 12, 24), + is_open=True, + ) + # Act + result = specified_opening_time.export_dos_log_format() + # Assert + assert result == ["2021-12-24-09:00-11:00"] + + +def test_specifiedopentime_export_dos_log_format_closed() -> None: + # Arrange + specified_opening_time = SpecifiedOpeningTime([], date(2021, 12, 24), is_open=False) + # Act + result = specified_opening_time.export_dos_log_format() + # Assert + assert result == ["2021-12-24-closed"] -def test_stdopeningtimes_eq(): +@pytest.mark.parametrize( + ("expected", "actual"), + [ + ({"2021-12-25": []}, SpecifiedOpeningTime([], date(2021, 12, 25))), + ( + {"2021-03-02": [{"start_time": "08:00", "end_time": "17:00"}]}, + SpecifiedOpeningTime([OpenPeriod(time(8, 0, 0), time(17, 0, 0))], date(2021, 3, 2)), + ), + ( + { + "2039-12-30": [ + {"start_time": "02:00", "end_time": "09:30"}, + {"start_time": "11:45", "end_time": "18:00"}, + ], + }, + SpecifiedOpeningTime( + [OpenPeriod(time(2, 0, 0), time(9, 30, 0)), OpenPeriod(time(11, 45, 0), time(18, 0, 0))], + date(2039, 12, 30), + ), + ), + ( + { + "2060-06-01": [ + {"start_time": "05:00", "end_time": "09:30"}, + {"start_time": "11:45", "end_time": "18:00"}, + {"start_time": "20:45", "end_time": "22:00"}, + ], + }, + SpecifiedOpeningTime( + [ + OpenPeriod(time(5, 0, 0), time(9, 30, 0)), + OpenPeriod(time(20, 45, 0), time(22, 0, 0)), + OpenPeriod(time(11, 45, 0), time(18, 0, 0)), + ], + date(2060, 6, 1), + ), + ), + ], +) +def test_specified_opening_time_export_test_format(expected: dict, actual: SpecifiedOpeningTime) -> None: + test_format = actual.export_test_format() + assert test_format == expected, f"expected {expected} SpecifiedOpeningTime change req format but got {test_format}" + + +def test_stdopeningtimes_eq_len() -> None: a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) b = OpenPeriod(time(13, 0, 0), time(17, 30, 0)) c = OpenPeriod(time(19, 0, 0), time(23, 59, 59)) @@ -453,23 +552,37 @@ def test_stdopeningtimes_eq(): st2 = StandardOpeningTimes() assert st1 == st2 + assert st1 != 23 + assert st1 != "Harry Potter" st1.monday.append(a) assert st1 != st2 + assert len(st1) == 1 st2.monday.append(a) assert st1 == st2 + assert len(st2) == 1 st2.friday += [a, b, c] st1.friday += [a, b] assert st1 != st2 + assert len(st1) == 3 + assert len(st2) == 4 st1.friday.append(c) assert st1 == st2 + assert len(st1) == 4 st1.sunday += [b, a, c] st2.sunday += [c, b, a] assert st1 == st2 + assert len(st1) == 7 + assert len(st2) == 7 + + st1.friday = [] + st2.friday = [] + assert len(st1) == 4 + assert len(st1) == 4 # Standard opening times should be equal even if generic bank holidays are not # this is expected behaviour because generic bank holidays in DoS are ignored. @@ -482,7 +595,7 @@ def test_stdopeningtimes_eq(): assert st1 == st2 -def test_stdopeningtimes_any_contradiction(): +def test_stdopeningtimes_any_contradiction() -> None: a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) b = OpenPeriod(time(13, 0, 0), time(17, 30, 0)) c = OpenPeriod(time(19, 0, 0), time(23, 59, 59)) @@ -511,3 +624,84 @@ def test_stdopeningtimes_any_contradiction(): st1.explicit_closed_days.add("wednesday") assert st1.any_contradictions() + + +def test_stdopeningtimes_export_opening_times_for_day() -> None: + # Arrange + a = OpenPeriod(time(8, 0, 0), time(12, 0, 0)) + b = OpenPeriod(time(13, 0, 0), time(18, 0, 0)) + st1 = StandardOpeningTimes() + st1.add_open_period(a, "monday") + st1.add_open_period(b, "monday") + # Act + response = st1.export_opening_times_for_day("monday") + # Assert + assert response == ["08:00-12:00", "13:00-18:00"] + + +def test_stdopeningtimes_export_opening_times_in_seconds_for_day() -> None: + # Arrange + a = OpenPeriod(time(9, 0, 0), time(13, 0, 0)) + b = OpenPeriod(time(14, 0, 0), time(19, 0, 0)) + st1 = StandardOpeningTimes() + st1.add_open_period(a, "monday") + st1.add_open_period(b, "monday") + # Act + response = st1.export_opening_times_in_seconds_for_day("monday") + # Assert + assert response == ["32400-46800", "50400-68400"] + + +def test_standard_opening_times_export_test_format() -> None: + # Start with empty + std_opening_times = StandardOpeningTimes() + expected = { + "Monday": [], + "Tuesday": [], + "Wednesday": [], + "Thursday": [], + "Friday": [], + "Saturday": [], + "Sunday": [], + } + assert std_opening_times.export_test_format() == expected + + # Add single opening time for monday + std_opening_times.monday.append(OpenPeriod(time(8, 0, 0), time(15, 0, 0))) + expected["Monday"].append({"start_time": "08:00", "end_time": "15:00"}) + assert std_opening_times.export_test_format() == expected + + # Add another to tuesday + std_opening_times.tuesday.append(OpenPeriod(time(8, 0, 0), time(20, 0, 0))) + expected["Tuesday"].append({"start_time": "08:00", "end_time": "20:00"}) + assert std_opening_times.export_test_format() == expected + + # Add another to monday + std_opening_times.monday.append(OpenPeriod(time(16, 0, 0), time(20, 0, 0))) + expected["Monday"].append({"start_time": "16:00", "end_time": "20:00"}) + assert std_opening_times.export_test_format() == expected + + # Add to every other day + for day in ["Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]: + getattr(std_opening_times, day.lower()).append(OpenPeriod(time(16, 0, 0), time(20, 0, 0))) + expected[day].append({"start_time": "16:00", "end_time": "20:00"}) + assert std_opening_times.export_test_format() == expected + + +def test_opening_period_times_from_list() -> None: + # Arrange + times = [OpenPeriod(time(8, 0, 0), time(9, 0, 0)), OpenPeriod(time(9, 0, 0), time(10, 0, 0))] + # Act + response = opening_period_times_from_list(times) + # Assert + assert response == "08:00-09:00, 09:00-10:00" + + +def test_std_open_times_fully_closed() -> None: + std_open_times = StandardOpeningTimes() + assert std_open_times.fully_closed() + + for day in WEEKDAYS: + std_open_times.add_open_period(OpenPeriod.from_string_times("08:00", "13:00"), day) + assert not std_open_times.fully_closed() + setattr(std_open_times, day, []) diff --git a/application/common/tests/test_secretsmanager.py b/application/common/tests/test_secretsmanager.py index 5b98be5a7..e8be6ef76 100644 --- a/application/common/tests/test_secretsmanager.py +++ b/application/common/tests/test_secretsmanager.py @@ -1,14 +1,14 @@ from json import dumps -from pytest import raises import boto3 -from moto import mock_secretsmanager +import pytest +from moto import mock_aws FILE_PATH = "application.common.secretsmanager" -@mock_secretsmanager -def test_get_secret(): +@mock_aws +def test_get_secret() -> None: from application.common.secretsmanager import get_secret # Arrangement @@ -22,9 +22,9 @@ def test_get_secret(): assert return_value == secret -@mock_secretsmanager -def test_get_secret_resource_not_found(): +@mock_aws +def test_get_secret_resource_not_found() -> None: from application.common.secretsmanager import get_secret - with raises(Exception, match="Failed getting secret 'fake_secret_name' from secrets manager"): + with pytest.raises(Exception, match="Failed getting secret 'fake_secret_name' from secrets manager"): get_secret("fake_secret_name") diff --git a/application/common/tests/test_service_type.py b/application/common/tests/test_service_type.py deleted file mode 100644 index b49d0df8a..000000000 --- a/application/common/tests/test_service_type.py +++ /dev/null @@ -1,109 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from common.change_event_exceptions import ValidationException -from pytest import raises - -from ..constants import DENTIST_ORG_TYPE_ID, PHARMACY_ORG_TYPE_ID, SERVICE_TYPES, VALID_SERVICE_TYPES_KEY -from ..service_type import get_valid_service_types, validate_organisation_keys, validate_organisation_type_id - -FILE_PATH = "application.common.service_type" - - -@pytest.mark.parametrize( - "org_type_id, org_sub_type", - [ - ( - "Dentist", - "TBA", - ), - ( - "PHA", - "Community", - ), - ], -) -@patch(f"{FILE_PATH}.validate_organisation_type_id") -def test_validate_organisation_keys( - mock_validate_organisation_type_id, - org_type_id, - org_sub_type, -): - # Act & Assert - validate_organisation_keys(org_type_id, org_sub_type) - - -@pytest.mark.parametrize( - "org_type_id, org_sub_type", - [ - ( - "Dentist", - "RANDOM", - ), - ( - "PHA", - "TEST1", - ), - ], -) -@patch(f"{FILE_PATH}.validate_organisation_type_id") -def test_validate_organisation_keys_org_sub_type_id_exception( - mock_validate_organisation_type_id, org_type_id, org_sub_type -): - # Act & Assert - with raises(ValidationException) as exception: - validate_organisation_keys(org_type_id, org_sub_type) - assert f"Unexpected Org Sub Type ID: '{org_sub_type}'" in str(exception.value) - - -@pytest.mark.parametrize("org_type_id", [PHARMACY_ORG_TYPE_ID, DENTIST_ORG_TYPE_ID]) -@patch(f"{FILE_PATH}.AppConfig") -def test_validate_organisation_type_id(mock_app_config, org_type_id): - # Arrange - feature_flags = MagicMock() - mock_app_config().get_feature_flags.return_value = feature_flags - feature_flags.evaluate.return_value = True - # Act - validate_organisation_type_id(org_type_id) - # Assert - feature_flags.evaluate.assert_called_once_with( - name="accepted_org_types", context={"org_type": org_type_id}, default=False - ) - - -@pytest.mark.parametrize("org_type_id", [PHARMACY_ORG_TYPE_ID, DENTIST_ORG_TYPE_ID]) -@patch(f"{FILE_PATH}.AppConfig") -def test_validate_organisation_type_id_wrong_org_type_id_exception(mock_app_config, org_type_id): - # Arrange - feature_flags = MagicMock() - mock_app_config().get_feature_flags.return_value = feature_flags - feature_flags.evaluate.return_value = False - # Act - with raises(ValidationException) as exception: - validate_organisation_type_id(org_type_id) - assert f"Unexpected Org Type ID: '{org_type_id}'" in str(exception.value) - # Assert - feature_flags.evaluate.assert_called_once_with( - name="accepted_org_types", context={"org_type": org_type_id}, default=False - ) - mock_app_config().get_raw_configuration.assert_called_once_with() - - -@pytest.mark.parametrize( - "org_type, expected_valid_service_types", - [ - ( - "Dentist", - SERVICE_TYPES["Dentist"][VALID_SERVICE_TYPES_KEY], - ), - ( - "PHA", - SERVICE_TYPES["PHA"][VALID_SERVICE_TYPES_KEY], - ), - ], -) -def test_get_valid_service_types(org_type, expected_valid_service_types): - # Act - valid_service_types = get_valid_service_types(org_type) - # Assert - assert expected_valid_service_types == valid_service_types diff --git a/application/common/tests/test_types.py b/application/common/tests/test_types.py new file mode 100644 index 000000000..f17939e29 --- /dev/null +++ b/application/common/tests/test_types.py @@ -0,0 +1,12 @@ +from application.common.types import UpdateRequest + + +def test_update_request() -> None: + # Arrange + change_event = {"ODSCode": "12345"} + service_id = "1" + # Act + response = UpdateRequest(change_event=change_event, service_id=service_id) + # Assert + assert change_event == response["change_event"] + assert service_id == response["service_id"] diff --git a/application/common/tests/test_utilities.py b/application/common/tests/test_utilities.py index 8ad87ee6c..b4a042d2d 100644 --- a/application/common/tests/test_utilities.py +++ b/application/common/tests/test_utilities.py @@ -1,17 +1,19 @@ from json import loads +import pytest from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord -from pytest import raises, mark -from ..utilities import ( +from application.common.utilities import ( extract_body, get_sequence_number, + get_sqs_msg_attribute, handle_sqs_msg_attributes, is_val_none_or_empty, + json_str_body, ) -def test_extract_body(): +def test_extract_body() -> None: # Arrange expected_change_event = '{"test": "test"}' # Act @@ -22,15 +24,30 @@ def test_extract_body(): ), f"Change event should be {loads(expected_change_event)} but is {change_event}" -def test_extract_body_exception(): +def test_extract_body_exception() -> None: # Arrange - expected_change_event = {"test": "test"} + expected_change_event = "test" # Act & Assert - with raises(Exception): + with pytest.raises(ValueError, match="Change Event unable to be extracted"): extract_body(expected_change_event) -def test_get_sequence_number(): +def test_json_str_body() -> None: + # Arrange + expected_json_str = '{"test": "test"}' + # Act + result = json_str_body({"test": "test"}) + # Assert + assert result == expected_json_str, f"Change event body should be {expected_json_str} str but is {result}" + + +def test_expected_json_str_exception() -> None: + # Act & Assert + with pytest.raises(TypeError, match="Object of type set is not JSON serializable"): + json_str_body(body={"not a json dict"}) + + +def test_get_sequence_number() -> None: # Arrange record = SQSRecord(SQS_EVENT["Records"][0]) # Act @@ -39,7 +56,7 @@ def test_get_sequence_number(): assert sequence_number == int(SQS_EVENT["Records"][0]["messageAttributes"]["sequence-number"]["stringValue"]) -def test_get_sequence_number_empty(): +def test_get_sequence_number_empty() -> None: # Arrange sqs_event = SQS_EVENT.copy() del sqs_event["Records"][0]["messageAttributes"]["sequence-number"] @@ -50,7 +67,46 @@ def test_get_sequence_number_empty(): assert sequence_number is None -def test_handle_sqs_msg_attributes(dead_letter_message): +def test_get_sqs_msg_attribute_string(dead_letter_message: dict[str, str]) -> None: + # Arrange + attribute = "error_msg" + msg_attributes = dead_letter_message["Records"][0]["messageAttributes"] + # Act + response = get_sqs_msg_attribute(msg_attributes=msg_attributes, key=attribute) + # Assert + assert response == msg_attributes[attribute]["stringValue"] + + +def test_get_sqs_msg_attribute_number(dead_letter_message: dict[str, str]) -> None: + # Arrange + attribute = "error_msg_http_code" + msg_attributes = dead_letter_message["Records"][0]["messageAttributes"] + # Act + response = get_sqs_msg_attribute(msg_attributes=msg_attributes, key=attribute) + # Assert + assert response == float(msg_attributes[attribute]["stringValue"]) + + +def test_get_sqs_msg_attribute_other(dead_letter_message: dict[str, str]) -> None: + # Arrange + attribute = "other" + msg_attributes = dead_letter_message["Records"][0]["messageAttributes"] + # Act + response = get_sqs_msg_attribute(msg_attributes=msg_attributes, key=attribute) + # Assert + assert response is None + + +def test_get_sqs_msg_attribute_no_attributes() -> None: + # Arrange + msg_attributes = {} + # Act + response = get_sqs_msg_attribute(msg_attributes=msg_attributes, key="error_msg") + # Assert + assert response is None + + +def test_handle_sqs_msg_attributes(dead_letter_message: dict[str, str]) -> None: msg_attributes = dead_letter_message["Records"][0]["messageAttributes"] attributes = handle_sqs_msg_attributes(msg_attributes=msg_attributes) @@ -58,8 +114,8 @@ def test_handle_sqs_msg_attributes(dead_letter_message): assert attributes["error_msg_http_code"] == "400" -@mark.parametrize("val,expected", [("", True), (" ", True), (None, True), ("True val", False)]) -def test_is_val_none_or_empty(val, expected): +@pytest.mark.parametrize(("val", "expected"), [("", True), (" ", True), (None, True), ("True val", False)]) +def test_is_val_none_or_empty(val: str | None, expected: bool) -> None: assert is_val_none_or_empty(val) == expected @@ -78,6 +134,6 @@ def test_is_val_none_or_empty(val, expected): "eventSource": "aws:sqs", "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", "awsRegion": "us-east-2", - } - ] + }, + ], } diff --git a/application/common/types.py b/application/common/types.py index 14a36b84a..30ed68858 100644 --- a/application/common/types.py +++ b/application/common/types.py @@ -1,17 +1,39 @@ -from typing import TypedDict, Dict +from typing import Any, TypedDict -class ChangeMetadata(TypedDict): +class HoldingQueueChangeEventItem(TypedDict): + """Represents a change event sent to the service matcher lambda via the holding queue.""" + + change_event: dict[str, Any] dynamo_record_id: str correlation_id: str + sequence_number: int message_received: int - ods_code: str - message_deduplication_id: str - message_group_id: str -class ChangeRequestQueueItem(TypedDict): - is_health_check: bool - change_request: Dict # could change this to Change Request potentially - recipient_id: str - metadata: ChangeMetadata +class UpdateRequest(TypedDict): + """Class to represent the update request payload.""" + + change_event: dict[str, Any] + service_id: str + + +class EmailFile(TypedDict): + """Class to represent the email file saved to S3.""" + + correlation_id: str + email_body: str + email_subject: str + user_id: str + + +class EmailMessage(TypedDict): + """Class to represent the email message for the send email lambda.""" + + change_id: str + correlation_id: str + email_body: str + email_subject: str + recipient_email_address: str + s3_filename: str + user_id: str diff --git a/application/common/utilities.py b/application/common/utilities.py index ad556fe6e..91af66892 100644 --- a/application/common/utilities.py +++ b/application/common/utilities.py @@ -1,59 +1,105 @@ -from json import loads -from typing import Any, Dict, Union -from aws_lambda_powertools import Logger +from json import dumps, loads +from typing import Any + +from aws_lambda_powertools.logging import Logger from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord +from common.types import UpdateRequest logger = Logger() -def is_val_none_or_empty(val: Any) -> bool: +def is_val_none_or_empty(val: Any) -> bool: # noqa: ANN401 + """Checks if the value is None or empty. + + Args: + val (Any): Value to check + + Returns: + bool: True if the value is None or empty, False otherwise + """ return not (val and not val.isspace()) -def extract_body(body: str) -> Dict[str, Any]: - """Extracts the event body from the lambda function invocation event +def extract_body(body: str) -> dict[str, Any] | UpdateRequest: + """Extracts the event body from the lambda function invocation event. Args: - message_body (str): A JSON string body + body (str): Lambda function invocation event body + Returns: - Dict[str, Any]: Message body as a dictionary + Dict[str, Any] | UpdateRequest: Message body as a dictionary """ try: - body = loads(body) - except Exception as e: - raise Exception("Change Event unable to be extracted") from e - return body + return loads(body) + except ValueError as e: + msg = "Change Event unable to be extracted" + raise ValueError(msg) from e -def get_sequence_number(record: SQSRecord) -> Union[int, None]: - """Gets the sequence number from the SQS record +def json_str_body(body: dict[str, Any]) -> str: + """Encode a Dict event body from the lambda function invocation event into a JSON string. + + Args: + body (Dict[str, Any]): A Dict body + + Returns: + (str): A JSON string body + """ + try: + return dumps(body) + except ValueError as e: + msg = "Dict Change Event body cannot be converted to a JSON string" + raise ValueError(msg) from e + + +def get_sequence_number(record: SQSRecord) -> int | None: + """Gets the sequence number from the SQS record sent by NHS UK. + Args: record (SQSRecord): SQS record + Returns: - Optional[int]: Sequence number of the message or None if not present + Optional[int]: Sequence number of the message or None if not present. """ seq_num_str = record.message_attributes.get("sequence-number", {}).get("stringValue") return None if seq_num_str is None else int(seq_num_str) -def get_sqs_msg_attribute(msg_attributes: Dict[str, Any], key: str) -> Union[str, float, None]: +def get_sqs_msg_attribute(msg_attributes: dict[str, Any], key: str) -> str | float | None: + """Gets the value of the given key from the SQS message attributes. + + Args: + msg_attributes (dict[str, Any]): Message attributes + key (str): Key to get the value for + + Returns: + str | float | None: Value of the given key or None if not present. + """ attribute = msg_attributes.get(key) if attribute is None: return None data_type = attribute.get("dataType") if data_type == "String": return attribute.get("stringValue") - if data_type == "Number": - return float(attribute.get("stringValue")) + return float(attribute.get("stringValue")) if data_type == "Number" else None + + +def handle_sqs_msg_attributes(msg_attributes: dict[str, Any]) -> dict[str, Any] | None: + """Extracts the error message and error message http code from the SQS message attributes. + Args: + msg_attributes (dict[str, Any]): Message attributes -def handle_sqs_msg_attributes(msg_attributes: Dict[str, Any]) -> Dict[str, Any]: - attributes = {"error_msg": "", "error_msg_http_code": ""} + Returns: + dict[str, Any]: Dictionary with error message and error message http code or None if not present. + """ if msg_attributes is not None: + attributes = {"error_msg": "", "error_msg_http_code": ""} if "error_msg_http_code" in msg_attributes: attributes["error_msg_http_code"] = msg_attributes["error_msg_http_code"]["stringValue"] if "error_msg" in msg_attributes: attributes["error_msg"] = msg_attributes["error_msg"]["stringValue"] return attributes + return None diff --git a/application/conftest.py b/application/conftest.py new file mode 100644 index 000000000..170ad04de --- /dev/null +++ b/application/conftest.py @@ -0,0 +1,216 @@ +import json +from collections.abc import Generator +from dataclasses import dataclass +from os import environ +from random import choices, randint, uniform + +import pytest +from aws_lambda_powertools.utilities.typing import LambdaContext +from boto3 import client +from moto import mock_aws +from testfixtures import LogCapture + +from application.common.dos import DoSLocation, DoSService +from application.common.opening_times import StandardOpeningTimes + +STD_EVENT_PATH = "application/test_resources/STANDARD_EVENT.json" +with open(STD_EVENT_PATH, encoding="utf8") as file: + PHARMACY_STANDARD_EVENT = json.load(file) + +STD_EVENT_STAFF_PATH = "application/test_resources/STANDARD_EVENT_WITH_STAFF.json" +with open(STD_EVENT_STAFF_PATH, encoding="utf8") as file: + PHARMACY_STANDARD_EVENT_STAFF = json.load(file) + + +@pytest.fixture() +def lambda_context() -> LambdaContext: + """Mock LambdaContext - All dummy values.""" + + @dataclass + class LambdaContext: + """Mock LambdaContext - All dummy values.""" + + function_name: str = "lambda" + memory_limit_in_mb: int = 128 + invoked_function_arn: str = "arn:aws:lambda:eu-west-1:000000000:function:lambda" + aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" + + return LambdaContext() + + +@pytest.fixture(autouse=True) +def _reset_standard_change_event() -> None: + """Reset the standard change event to its original state.""" + with open(STD_EVENT_PATH, encoding="utf8") as file: + PHARMACY_STANDARD_EVENT.clear() + PHARMACY_STANDARD_EVENT.update(json.load(file)) + + +def get_std_event(**kwargs: str) -> dict: + """Creates a standard event with random data for the unit testing.""" + event = PHARMACY_STANDARD_EVENT.copy() + for name, value in kwargs.items(): + if value is not None: + event[name] = value + return event + + +def dummy_dos_service(**kwargs: str) -> DoSService: + """Creates a DoSService Object with random data for the unit testing.""" + test_data = {} + for col in DoSService.field_names(): + random_str = "".join(choices("ABCDEFGHIJKLM", k=8)) + test_data[col] = random_str + dos_service = DoSService(test_data) + dos_service.standard_opening_times = StandardOpeningTimes() + dos_service.specified_opening_times = [] + + for name, value in kwargs.items(): + if value is not None: + setattr(dos_service, name, value) + + return dos_service + + +def blank_dos_service(**kwargs: str) -> DoSService: + """Creates a DoSService Object with blank str data for the unit testing.""" + test_data = {col: "" for col in DoSService.field_names()} + dos_service = DoSService(test_data) + + for name, value in kwargs.items(): + if value is not None: + setattr(dos_service, name, value) + + return dos_service + + +def dummy_dos_location() -> DoSLocation: + """Creates a DoSLocation Object with random data for the unit testing.""" + return DoSLocation( + id=randint(1111, 9999), + postcode="".join(choices("01234567890ABCDEFGHIJKLM", k=6)), + easting=randint(1111, 9999), + northing=randint(1111, 9999), + postaltown="".join(choices("01234567890ABCDEFGHIJKLM", k=8)), + latitude=uniform(-200.0, 200.0), + longitude=uniform(-200.0, 200.0), + ) + + +@pytest.fixture() +def change_event() -> dict: + """Generate a change event for testing.""" + return PHARMACY_STANDARD_EVENT.copy() + + +@pytest.fixture() +def change_event_staff() -> dict: + """Get a standard change event with staff.""" + return PHARMACY_STANDARD_EVENT_STAFF.copy() + + +@pytest.fixture() +def _aws_credentials() -> None: + """Mocked AWS Credentials for moto.""" + environ["AWS_ACCESS_KEY_ID"] = "testing" + environ["AWS_SECRET_ACCESS_KEY"] = "testing" # noqa: S105 + environ["AWS_SECURITY_TOKEN"] = "testing" # noqa: S105 + environ["AWS_SESSION_TOKEN"] = "testing" # noqa: S105 + environ["CHANGE_EVENTS_TABLE_NAME"] = "CHANGE_EVENTS_TABLE" + environ["AWS_REGION"] = "us-east-2" + + +@pytest.fixture() +def dynamodb_client(_aws_credentials: None) -> Generator[object, None, None]: + """DynamoDB Client Class.""" + with mock_aws(): + yield client("dynamodb", region_name=environ["AWS_REGION"]) + + +@pytest.fixture() +def dead_letter_message() -> dict: + """Generate a dead letter message for testing.""" + return { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": "Test message.", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185", + }, + "messageAttributes": { + "error_msg": { + "stringValue": "ApiDestination returned HTTP status 400 with payload: Dummy", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String", + }, + "error_msg_http_code": { + "stringValue": "400", + "stringListValues": [], + "binaryListValues": [], + "dataType": "Number", + }, + "other": { + "stringValue": "other", + "stringListValues": [], + "binaryListValues": [], + "dataType": "other", + }, + "correlation-id": { + "stringValue": "test", + "stringListValues": [], + "binaryListValues": [], + "dataType": "String", + }, + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:cr-fifo-dlq-queue", + "awsRegion": "us-east-2", + }, + ], + } + + +@pytest.fixture() +def log_capture() -> LogCapture: + """Capture logs. + + Yields: + LogCapture: Log capture + """ + with LogCapture(names="lambda") as capture: + yield capture + + +@pytest.fixture() +def dynamodb_table_create(dynamodb_client: object) -> object: + """Create a DynamoDB CHANGE_EVENTS_TABLE table pytest.fixture.""" + return dynamodb_client.create_table( + TableName=environ["CHANGE_EVENTS_TABLE_NAME"], + BillingMode="PAY_PER_REQUEST", + KeySchema=[ + {"AttributeName": "Id", "KeyType": "HASH"}, + {"AttributeName": "ODSCode", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "Id", "AttributeType": "S"}, + {"AttributeName": "ODSCode", "AttributeType": "S"}, + {"AttributeName": "SequenceNumber", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[ + { + "IndexName": "gsi_ods_sequence", + "KeySchema": [ + {"AttributeName": "ODSCode", "KeyType": "HASH"}, + {"AttributeName": "SequenceNumber", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }, + ], + ) diff --git a/application/cr_fifo_dlq_handler/cr_fifo_dlq_handler.py b/application/cr_fifo_dlq_handler/cr_fifo_dlq_handler.py deleted file mode 100644 index 4efd72b83..000000000 --- a/application/cr_fifo_dlq_handler/cr_fifo_dlq_handler.py +++ /dev/null @@ -1,48 +0,0 @@ -from aws_lambda_powertools import Logger, Tracer -from aws_lambda_powertools.utilities.data_classes import SQSEvent, event_source -from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext -from aws_embedded_metrics import metric_scope -from common.middlewares import unhandled_exception_logging -from common.utilities import extract_body, get_sqs_msg_attribute -from common.constants import DLQ_HANDLER_REPORT_ID - -TTL = 157680000 # int((365*5)*24*60*60) . 5 years in seconds -tracer = Tracer() -logger = Logger() - - -@unhandled_exception_logging() -@tracer.capture_lambda_handler() -@event_source(data_class=SQSEvent) -@logger.inject_lambda_context() -@metric_scope -def lambda_handler(event: SQSEvent, context: LambdaContext, metrics) -> None: - """Entrypoint handler for the lambda - - Args: - event (SQSEvent): Lambda function invocation event (list of 1 SQS Message) - context (LambdaContext): Lambda function context object - """ - record = next(event.records) - message = record.body - body = extract_body(message) - correlation_id = get_sqs_msg_attribute(record.message_attributes, "correlation_id") - error_msg = get_sqs_msg_attribute(record.message_attributes, "error_msg") - logger.set_correlation_id(correlation_id) - logger.append_keys(dynamo_record_id=get_sqs_msg_attribute(record.message_attributes, "dynamo_record_id")) - logger.append_keys(message_received=get_sqs_msg_attribute(record.message_attributes, "message_received")) - logger.append_keys(ods_code=get_sqs_msg_attribute(record.message_attributes, "ods_code")) - logger.warning( - "Change Request DLQ Handler hit", - extra={ - "report_key": DLQ_HANDLER_REPORT_ID, - "error_msg": f"Message Abandoned: {error_msg}", - "error_msg_http_code": get_sqs_msg_attribute(record.message_attributes, "error_msg_http_code"), - "change_payload": body, - }, - ) - metrics.set_namespace("AWS/SQS") - metrics.set_property("level", "WARNING") - metrics.set_property("message", error_msg) - metrics.set_property("correlation_id", logger.get_correlation_id()) - metrics.put_metric("NumberOfMessagesReceived", 1, "Count") diff --git a/application/cr_fifo_dlq_handler/requirements.txt b/application/cr_fifo_dlq_handler/requirements.txt deleted file mode 100644 index e6a88cfcb..000000000 --- a/application/cr_fifo_dlq_handler/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -aws-embedded-metrics -aws-lambda-powertools diff --git a/application/dos_api_gateway/dos_api_gateway.py b/application/dos_api_gateway/dos_api_gateway.py deleted file mode 100644 index 7642a2522..000000000 --- a/application/dos_api_gateway/dos_api_gateway.py +++ /dev/null @@ -1,48 +0,0 @@ -from json import loads, dumps -from os import getenv -from typing import Dict, Any -from aws_lambda_powertools.utilities.typing import LambdaContext -from aws_lambda_powertools import Logger -from time import sleep - -logger = Logger() - - -@logger.inject_lambda_context -def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]: - """Entrypoint handler for the authoriser lambda - - Args: - event (Dict[str, Any]): Lambda function invocation event - context (LambdaContext): Lambda function context object - - Returns: - dict: Response to change request - """ - logger.info("Event Received", extra={"event": event}) - change_request = loads(event["body"]) - sleep(1.7) - if change_request == {}: - logger.warning("Empty change request received, likely a health check") - return {"statusCode": 400, "body": "Change Request is empty"} - - correlation_id = change_request["reference"] - logger.set_correlation_id(correlation_id) - logger.info("MOCK DoS API Gateway - Change request received", extra={"change_request": event}) - - if "bad request" in correlation_id.lower(): - logger.warning("MOCK DoS API Gateway - Returning Fake Bad Request", extra={"change_request": event}) - return {"statusCode": 400, "body": "Fake Bad Request trigged by correlation-id"} - - if getenv("CHAOS_MODE") == "true": - logger.warning("CHAOS MODE ENABLED - Returning a 500 response") - return {"statusCode": 500, "body": "Chaos mode is enabled"} - - change_request_response = {"dosChanges": []} - - if "changes" in change_request: - counter = 1 - for row in change_request["changes"]: - change_request_response["dosChanges"].append({"changeId": str(counter) * 9}) - counter += 1 - return {"statusCode": 201, "body": dumps(change_request_response)} diff --git a/application/dos_api_gateway/requirements.txt b/application/dos_api_gateway/requirements.txt deleted file mode 100644 index 56fd45918..000000000 --- a/application/dos_api_gateway/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -aws-lambda-powertools diff --git a/application/dos_api_gateway/tests/test_dos_api_gateway.py b/application/dos_api_gateway/tests/test_dos_api_gateway.py deleted file mode 100644 index f6dfa2638..000000000 --- a/application/dos_api_gateway/tests/test_dos_api_gateway.py +++ /dev/null @@ -1,53 +0,0 @@ -from json import dumps, loads -import pytest -from dataclasses import dataclass - -from ..dos_api_gateway import lambda_handler - - -@pytest.fixture -def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "event-sender" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:event-sender" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" - - return LambdaContext() - - -def test_lambda_handler(lambda_context): - # Arrange - change_request = { - "reference": "1", - "system": "DoS Integration", - "message": "Test message 153181659229", - "service_id": "49016", - "changes": {"website": "https://www.google.pl", "public_name": "Test Name"}, - } - lambda_event = {} - lambda_event["body"] = dumps(change_request) - # Act - response = lambda_handler(lambda_event, lambda_context) - # Assert - assert response["statusCode"] == 201 - assert loads(response["body"]) == {"dosChanges": [{"changeId": "1" * 9}, {"changeId": "2" * 9}]} - - -def test_lambda_handler_forced_bad_request(lambda_context): - # Arrange - change_request = { - "reference": "bad request dummy_correlation_id", - "system": "DoS Integration", - "message": "Test message 153181659229", - "service_id": "49016", - "changes": {"website": "https://www.google.pl", "public_name": "Test Name"}, - } - lambda_event = {} - lambda_event["body"] = dumps(change_request) - # Act - response = lambda_handler(lambda_event, lambda_context) - # Assert - assert response["statusCode"] == 400 - assert response["body"] == "Fake Bad Request trigged by correlation-id" diff --git a/application/cr_fifo_dlq_handler/__init__.py b/application/dos_db_handler/__init__.py similarity index 100% rename from application/cr_fifo_dlq_handler/__init__.py rename to application/dos_db_handler/__init__.py diff --git a/application/dos_db_handler/dos_db_handler.py b/application/dos_db_handler/dos_db_handler.py new file mode 100644 index 000000000..f7c540e2d --- /dev/null +++ b/application/dos_db_handler/dos_db_handler.py @@ -0,0 +1,84 @@ +from json import dumps +from typing import Any + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext + +from common.dos import SpecifiedOpeningTime, get_specified_opening_times_from_db, get_standard_opening_times_from_db +from common.dos_db_connection import connect_to_db_writer, query_dos_db +from common.middlewares import unhandled_exception_logging + +logger = Logger() + + +@unhandled_exception_logging() +@logger.inject_lambda_context(clear_state=True) +def lambda_handler(event: dict[str, Any], context: LambdaContext) -> str: # noqa: ARG001 + """Entrypoint handler for the lambda. + + WARNING: This lambda is for TESTING PURPOSES ONLY. + It is not intended to be used in production. + + Args: + event (Dict[str, Any]): Lambda function invocation event + context (LambdaContext): Lambda function context object + """ + request = event + result = None + generic_queries = ["write", "read", "insert"] + + if request["type"] in generic_queries: + query = request["query"] + query_vars = request["query_vars"] + + result = run_query(query, query_vars) + + if request["type"] == "write": + # returns a single value (typically id) + return dumps(result, default=str)[0][0] + elif request["type"] == "read": # noqa: RET505 + # returns all values + return dumps(result, default=str) + elif request["type"] == "insert": + # returns no values + return "True" + elif request["type"] == "change_event_standard_opening_times": + service_id = request.get("service_id") + if service_id is None: + msg = "Missing service_id" + raise ValueError(msg) + with connect_to_db_writer() as connection: + standard_opening_times = get_standard_opening_times_from_db(connection=connection, service_id=service_id) + return standard_opening_times.export_test_format() + elif request["type"] == "change_event_specified_opening_times": + service_id = request.get("service_id") + if service_id is None: + msg = "Missing service_id" + raise ValueError(msg) + with connect_to_db_writer() as connection: + specified_opening_times = get_specified_opening_times_from_db(connection=connection, service_id=service_id) + return SpecifiedOpeningTime.export_test_format_list(specified_opening_times) + else: + # add comment + msg = "Unsupported request" + raise ValueError(msg) + + +def run_query(query: str, query_vars: dict) -> list: + """Run a query against the database. + + Args: + query (str): Query to run + query_vars (dict): Query variables + + Returns: + list: Query result + """ + logger.info("Running query", query=query) + with connect_to_db_writer() as connection: + cursor = query_dos_db(connection=connection, query=query, query_vars=query_vars) + query_result = cursor.fetchall() + connection.commit() + cursor.close() + logger.warning("Query result", query_result=query_result) + return query_result diff --git a/application/dos_db_handler/requirements.txt b/application/dos_db_handler/requirements.txt new file mode 100644 index 000000000..a5f3571d4 --- /dev/null +++ b/application/dos_db_handler/requirements.txt @@ -0,0 +1,2 @@ +aws-lambda-powertools[tracer] ~= 2.43.0 +psycopg[binary] diff --git a/application/cr_fifo_dlq_handler/tests/__init__.py b/application/dos_db_update_dlq_handler/__init__.py similarity index 100% rename from application/cr_fifo_dlq_handler/tests/__init__.py rename to application/dos_db_update_dlq_handler/__init__.py diff --git a/application/dos_db_update_dlq_handler/dos_db_update_dlq_handler.py b/application/dos_db_update_dlq_handler/dos_db_update_dlq_handler.py new file mode 100644 index 000000000..079755a38 --- /dev/null +++ b/application/dos_db_update_dlq_handler/dos_db_update_dlq_handler.py @@ -0,0 +1,46 @@ +from os import getenv + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.tracing import Tracer +from aws_lambda_powertools.utilities.data_classes import SQSEvent, event_source +from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext + +from common.middlewares import unhandled_exception_logging +from common.utilities import extract_body, get_sqs_msg_attribute + +TTL = 157680000 # int((365*5)*24*60*60) . 5 years in seconds +tracer = Tracer() +logger = Logger() +DOS_DB_UPDATE_DLQ_HANDLER_EVENT = "DOS_DB_UPDATE_DLQ_HANDLER_RECEIVED_EVENT" + + +@unhandled_exception_logging() +@tracer.capture_lambda_handler() +@event_source(data_class=SQSEvent) +@logger.inject_lambda_context( + clear_state=True, + correlation_id_path='Records[0].messageAttributes."correlation-id".stringValue', +) +def lambda_handler(event: SQSEvent, context: LambdaContext) -> None: # noqa: ARG001 + """Entrypoint handler for the lambda. + + Args: + event (SQSEvent): Lambda function invocation event (list of 1 SQS Message) + context (LambdaContext): Lambda function context object + """ + record = next(event.records) + message = record.body + body = extract_body(message) + error_msg = get_sqs_msg_attribute(record.message_attributes, "error_msg") + logger.warning( + "DoS DB Update DLQ Handler hit", + report_key=DOS_DB_UPDATE_DLQ_HANDLER_EVENT, + error_msg=f"Message Abandoned: {error_msg}", + error_msg_http_code=get_sqs_msg_attribute(record.message_attributes, "error_msg_http_code"), + change_payload=body, + dynamo_record_id=get_sqs_msg_attribute(record.message_attributes, "dynamo_record_id"), + message_received=get_sqs_msg_attribute(record.message_attributes, "message_received"), + ods_code=get_sqs_msg_attribute(record.message_attributes, "ods_code"), + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="DoSDBUpdateDLQHandlerReceivedEvent", + ) diff --git a/application/dos_db_update_dlq_handler/requirements.txt b/application/dos_db_update_dlq_handler/requirements.txt new file mode 100644 index 000000000..e27f685d9 --- /dev/null +++ b/application/dos_db_update_dlq_handler/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools[tracer] ~= 2.43.0 diff --git a/application/dos_api_gateway/__init__.py b/application/dos_db_update_dlq_handler/tests/__init__.py similarity index 100% rename from application/dos_api_gateway/__init__.py rename to application/dos_db_update_dlq_handler/tests/__init__.py diff --git a/application/cr_fifo_dlq_handler/tests/test_cr_fifo_dlq_handler.py b/application/dos_db_update_dlq_handler/tests/test_dos_db_update_dlq_handler.py similarity index 67% rename from application/cr_fifo_dlq_handler/tests/test_cr_fifo_dlq_handler.py rename to application/dos_db_update_dlq_handler/tests/test_dos_db_update_dlq_handler.py index 4aadf4aad..c7ef56bb7 100644 --- a/application/cr_fifo_dlq_handler/tests/test_cr_fifo_dlq_handler.py +++ b/application/dos_db_update_dlq_handler/tests/test_dos_db_update_dlq_handler.py @@ -1,30 +1,15 @@ -from dataclasses import dataclass -from unittest.mock import patch +from os import environ +from unittest.mock import MagicMock, patch -from pytest import fixture +from aws_lambda_powertools.utilities.typing import LambdaContext -from ..cr_fifo_dlq_handler import lambda_handler -from aws_embedded_metrics.logger.metrics_logger import MetricsLogger +from application.dos_db_update_dlq_handler.dos_db_update_dlq_handler import lambda_handler -FILE_PATH = "application.cr_fifo_dlq_handler.cr_fifo_dlq_handler" - - -@fixture -def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "cr-fifo-dlq-handler" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:cr-fifo-dlq-handler" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" - - return LambdaContext() +FILE_PATH = "application.dos_db_update_dlq_handler.dos_db_update_dlq_handler" @patch(f"{FILE_PATH}.extract_body") -@patch.object(MetricsLogger, "put_metric") -@patch.object(MetricsLogger, "set_dimensions") -def test_lambda_handler(mock_put_metric, mock_set_dimentions, mock_extract_body, lambda_context): +def test_lambda_handler(mock_extract_body: MagicMock, lambda_context: LambdaContext) -> None: # Arrange change_request = { "reference": "Dummy correlation id", @@ -64,11 +49,14 @@ def test_lambda_handler(mock_put_metric, mock_set_dimentions, mock_extract_body, "dataType": "String", }, }, - } - ] + }, + ], } + environ["ENV"] = "test" mock_extract_body.return_value = extracted_body # Act lambda_handler(dead_letter_message, lambda_context) # Assert mock_extract_body.assert_called_once_with(dead_letter_message["Records"][0]["body"]) + # Clean up + del environ["ENV"] diff --git a/application/event_processor/change_event_validation.py b/application/event_processor/change_event_validation.py deleted file mode 100644 index c5554b485..000000000 --- a/application/event_processor/change_event_validation.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Any, Dict - -from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.validation import validate -from aws_lambda_powertools.utilities.validation.exceptions import SchemaValidationError - -from common.change_event_exceptions import ValidationException -from common.constants import ODSCODE_LENGTH_KEY, SERVICE_TYPES -from common.service_type import validate_organisation_keys - -logger = Logger(child=True) - - -def validate_event(event: Dict[str, Any]) -> None: - """Validate event using business rules - Args: - event (Dict[str, Any]): Lambda function invocation event - """ - logger.info(f"Attempting to validate event payload: {event}") - try: - validate(event=event, schema=INPUT_SCHEMA) - except SchemaValidationError as exception: - raise ValidationException(exception) - validate_organisation_keys(event.get("OrganisationTypeId"), event.get("OrganisationSubType")) - check_ods_code_length(event["ODSCode"], SERVICE_TYPES[event["OrganisationTypeId"]][ODSCODE_LENGTH_KEY]) - logger.info("Event has been validated") - - -def check_ods_code_length(odscode: str, odscode_length: int) -> None: - """Check ODS code length as expected, exception raise if error - Note: ods code type is checked by schema validation - Args: - odscode (str): odscode of NHS UK service - """ - logger.debug(f"Checking ODSCode {odscode} length") - if len(odscode) != odscode_length: - raise ValidationException(f"ODSCode Wrong Length, '{odscode}' is not length {odscode_length}.") - - -INPUT_SCHEMA = { - "$schema": "https://json-schema.org/draft-07/schema", - "type": "object", - "required": ["ODSCode", "OrganisationTypeId", "OrganisationSubType"], - "properties": { - "ODSCode": { - "$id": "#/properties/ODSCode", - "type": "string", - }, - "OrganisationTypeId": { - "$id": "#/properties/OrganisationTypeId", - "type": "string", - }, - "OrganisationSubType": { - "$id": "#/properties/OrganisationSubType", - "type": "string", - }, - }, - "additionalProperties": "true", -} diff --git a/application/event_processor/change_request.py b/application/event_processor/change_request.py deleted file mode 100644 index 95f6abb91..000000000 --- a/application/event_processor/change_request.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any, Dict -from aws_lambda_powertools import Logger - - -ADDRESS_CHANGE_KEY = "address" -ADDRESS_LINES_KEY = "address_lines" -PHONE_CHANGE_KEY = "phone" -POSTCODE_CHANGE_KEY = "post_code" -PUBLICNAME_CHANGE_KEY = "public_name" -WEBSITE_CHANGE_KEY = "website" -OPENING_DATES_KEY = "opening_dates" -OPENING_DAYS_KEY = "opening_days" - - -logger = Logger(child=True) - - -class ChangeRequest: - changes: Dict[str, Any] - - def __init__(self, service_id: int, changes: Any = None): - correlation_id = logger.get_correlation_id() - - self.reference = correlation_id - self.system = "DoS Integration" - self.message = f"DoS Integration CR. correlation-id: {correlation_id}" - self.replace_opening_dates_mode = True - self.service_id = str(service_id) - self.changes = changes - if self.changes is None: - self.changes = {} - - def create_payload(self) -> Dict[str, Any]: - """Creates the payload for the change request - - Returns: - Dict[str, Any]: The change request payload - """ - return { - "reference": self.reference, - "system": self.system, - "message": self.message, - "replace_opening_dates_mode": self.replace_opening_dates_mode, - "service_id": self.service_id, - "changes": self.changes, - } diff --git a/application/event_processor/changes.py b/application/event_processor/changes.py deleted file mode 100644 index c2f99f543..000000000 --- a/application/event_processor/changes.py +++ /dev/null @@ -1,145 +0,0 @@ -from typing import Any, Dict - -from aws_lambda_powertools import Logger -from change_request import ( - ADDRESS_CHANGE_KEY, - ADDRESS_LINES_KEY, - OPENING_DATES_KEY, - OPENING_DAYS_KEY, - PHONE_CHANGE_KEY, - POSTCODE_CHANGE_KEY, - PUBLICNAME_CHANGE_KEY, - WEBSITE_CHANGE_KEY, -) -from common.dos import DoSService, get_valid_dos_postcode -from common.opening_times import SpecifiedOpeningTime -from common.utilities import is_val_none_or_empty -from nhs import NHSEntity -from reporting import log_invalid_nhsuk_postcode - -logger = Logger(child=True) - - -def get_changes(dos_service: DoSService, nhs_entity: NHSEntity) -> Dict[str, str]: - """Returns a dict of the changes that are required to get - the service inline with the given nhs_entity. - """ - changes = {} - update_changes(changes, WEBSITE_CHANGE_KEY, dos_service.web, nhs_entity.website) - update_changes(changes, PHONE_CHANGE_KEY, dos_service.publicphone, nhs_entity.phone) - update_changes(changes, PUBLICNAME_CHANGE_KEY, dos_service.publicname, nhs_entity.org_name) - update_changes_with_address_and_postcode(changes, dos_service, nhs_entity) - update_changes_with_opening_times(changes, dos_service, nhs_entity) - return changes - - -def update_changes(changes: dict, change_key: str, dos_value: Any, nhs_uk_value: Any) -> None: - """Adds field to the change request if the field is not equal - Args: - changes (dict): Change Request changes - change_key (str): Key to add to the change request - dos_value (str|None): Field from the DoS database for comparision - nhs_uk_value (str|None): NHS UK Entity value for comparision - - Returns: - dict: Change Request changes - """ - if str(dos_value) != str(nhs_uk_value) and ( - not is_val_none_or_empty(dos_value) or not is_val_none_or_empty(nhs_uk_value) - ): - logger.debug(f"{change_key} is not equal, {dos_value=} != {nhs_uk_value=}") - if nhs_uk_value is None: - changes[change_key] = "" - else: - changes[change_key] = nhs_uk_value - - -def update_changes_with_address(changes: dict, dos_service: DoSService, nhs_uk_entity: NHSEntity) -> dict: - """Adds the address to the change request if the address is not equal - - Args: - changes (dict): Change Request changes - change_key (str): Key to add to the change request - dos_address (str): Address from the DoS database for comparision - nhs_uk_entity (NHSEntity): NHS UK Entity for comparision - - Returns: - dict: Change Request changes - """ - nhs_uk_address_string = "$".join(nhs_uk_entity.address_lines) - dos_address = dos_service.address - - if dos_address != nhs_uk_address_string: - logger.debug(f"Address is not equal, {dos_address=} != {nhs_uk_address_string=}") - changes[ADDRESS_CHANGE_KEY] = {ADDRESS_LINES_KEY: nhs_uk_entity.address_lines} - - return changes - - -def update_changes_with_opening_times(changes: dict, dos_service: DoSService, nhs_entity: NHSEntity) -> None: - """Adds the standard opening times and specified opening times to the change request if not equal and - nhsuk times are valid. - - Args: - changes (dict): Change Request changes - dos_service (DoSService): DoS Service for comparision - nhs_entity (NHSEntity): NHS UK Entity for comparision - """ - - # Skip if invalid times. This check will have already been done and logged out fully in event_processor - if not nhs_entity.all_times_valid(): - logger.warning( - f"Opening Times for NHS Entity '{nhs_entity.odscode}' were previously found to be invalid or illogical. " - "Skipping change." - ) - return - - # SPECIFIED OPENING TIMES (Comparing a list of SpecifiedOpeningTimes) - dos_spec_open_dates = dos_service.get_specified_opening_times() - nhs_spec_open_dates = nhs_entity.specified_opening_times - compared = SpecifiedOpeningTime.equal_lists(dos_spec_open_dates, nhs_spec_open_dates) - if not compared: - logger.debug( - "Specified opening times not equal", extra={"dos": dos_spec_open_dates, "nhs": nhs_spec_open_dates} - ) - changes[OPENING_DATES_KEY] = SpecifiedOpeningTime.export_cr_format_list(nhs_spec_open_dates) - else: - logger.debug( - "Specified opening times are equal, so no change", - extra={"dos": dos_spec_open_dates, "nhs": nhs_spec_open_dates, "compared": compared}, - ) - - # STANDARD OPENING TIMES (Comparing single StandardOpeningTimes Objects) - dos_std_open_dates = dos_service.get_standard_opening_times() - nhs_std_open_dates = nhs_entity.standard_opening_times - if dos_std_open_dates != nhs_std_open_dates: - logger.debug(f"Standard weekly opening times not equal. dos={dos_std_open_dates} nhs={nhs_std_open_dates}") - changes[OPENING_DAYS_KEY] = nhs_std_open_dates.export_cr_format() - - -def update_changes_with_address_and_postcode(changes: dict, dos_service: DoSService, nhs_entity: NHSEntity) -> None: - - nhs_uk_address_string = "$".join(nhs_entity.address_lines) - dos_address = dos_service.address - is_address_same = True - if dos_address != nhs_uk_address_string: - is_address_same = False - logger.debug(f"Address is not equal, {dos_address=} != {nhs_uk_address_string=}") - changes[ADDRESS_CHANGE_KEY] = {ADDRESS_LINES_KEY: nhs_entity.address_lines} - - dos_postcode = dos_service.normal_postcode() - nhs_postcode = nhs_entity.normal_postcode() - if dos_postcode != nhs_postcode: - logger.debug(f"Postcode is not equal, {dos_postcode=} != {nhs_postcode=}") - - valid_dos_postcode = get_valid_dos_postcode(nhs_postcode) - if valid_dos_postcode is None: - log_invalid_nhsuk_postcode(nhs_entity, dos_service) - if ADDRESS_CHANGE_KEY in changes: - del changes[ADDRESS_CHANGE_KEY] - logger.info("Deleted address change as postcode is invalid") - else: - if is_address_same: - changes[ADDRESS_CHANGE_KEY] = {ADDRESS_LINES_KEY: nhs_entity.address_lines} - logger.debug(f"Address is equal but Postcode is not equal, {dos_postcode=} != {nhs_postcode=}") - changes[ADDRESS_CHANGE_KEY][POSTCODE_CHANGE_KEY] = valid_dos_postcode diff --git a/application/event_processor/event_processor.py b/application/event_processor/event_processor.py deleted file mode 100644 index 1d5cb609e..000000000 --- a/application/event_processor/event_processor.py +++ /dev/null @@ -1,276 +0,0 @@ -import hashlib -from json import dumps -from os import environ -from time import gmtime, strftime, time_ns -from typing import Dict, List, Union - -from aws_embedded_metrics import metric_scope -from aws_lambda_powertools import Logger, Tracer -from aws_lambda_powertools.utilities.data_classes import SQSEvent, event_source -from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext -from boto3 import client -from change_event_validation import validate_event -from change_request import ChangeRequest -from changes import get_changes -from common.constants import DENTIST_ORG_TYPE_ID, PHARMACY_ORG_TYPE_ID -from common.dos import VALID_STATUS_ID, DoSService, get_matching_dos_services -from common.dos_db_connection import disconnect_dos_db -from common.dynamodb import add_change_request_to_dynamodb, get_latest_sequence_id_for_a_given_odscode_from_dynamodb -from common.middlewares import set_correlation_id, unhandled_exception_logging -from common.service_type import get_valid_service_types -from common.utilities import extract_body, get_sequence_number -from nhs import NHSEntity -from reporting import ( - log_closed_or_hidden_services, - log_invalid_open_times, - log_service_with_generic_bank_holiday, - log_unmatched_nhsuk_service, - log_unmatched_service_types, -) - -logger = Logger() -tracer = Tracer() -EXPECTED_ENVIRONMENT_VARIABLES = ( - "DB_SERVER", - "DB_PORT", - "DB_NAME", - "DB_SCHEMA", - "DB_USER_NAME", -) - - -def divide_chunks(to_chunk, chunk_size): - - # looping till length l - for i in range(0, len(to_chunk), chunk_size): - yield to_chunk[i : i + chunk_size] # noqa: E203 - - -class EventProcessor: - matching_services = None - change_requests = None - - def __init__(self, nhs_entity: NHSEntity): - self.nhs_entity = nhs_entity - - def get_matching_services(self) -> List[DoSService]: - """Using the nhs entity attributed to this object, it finds the - matching DoS services from the db and filters the results - """ - - # Check database for services with same first 5 digits of ODSCode - logger.info(f"Getting matching DoS Services for odscode '{self.nhs_entity.odscode}'.") - matching_dos_services = get_matching_dos_services(self.nhs_entity.odscode, self.nhs_entity.org_type_id) - - # Filter for matched and unmatched service types and valid status - matching_services, non_matching_services = [], [] - valid_service_types = get_valid_service_types(self.nhs_entity.org_type_id) - for service in matching_dos_services: - if int(service.statusid) == VALID_STATUS_ID: - if int(service.typeid) in valid_service_types: - matching_services.append(service) - else: - non_matching_services.append(service) - if len(non_matching_services) > 0: - log_unmatched_service_types(self.nhs_entity, non_matching_services) - - if self.nhs_entity.org_type_id == PHARMACY_ORG_TYPE_ID: - logger.info( - f"Found {len(matching_dos_services)} services in DB with " - f"matching first 5 chars of ODSCode: {matching_dos_services}" - ) - elif self.nhs_entity.org_type_id == DENTIST_ORG_TYPE_ID: - logger.info( - f"Found {len(matching_dos_services)} services in DB with matching ODSCode: {matching_dos_services}" - ) - logger.info( - f"Found {len(matching_services)} services with typeid in " - f"allowlist {valid_service_types} and status id = " - f"{VALID_STATUS_ID}: {matching_services}" - ) - - self.matching_services = matching_services - return self.matching_services - - def get_change_requests(self) -> Union[Dict[str, str], None]: - """Generates change requests needed for the found services to make them inline with the NHS Entity - - Returns: - Union[Dict[str, str], None]: A dictionary of change requests or none. - """ - if self.matching_services is None: - logger.error("Attempting to form change requests before matching services have been found.") - return None - - change_requests = [] - for service in self.matching_services: - - # Find changes, don't make a change req if none found - changes = get_changes(service, self.nhs_entity) - logger.info(f"Changes for nhs:{self.nhs_entity.odscode}/dos:{service.id} : {changes}") - if len(changes) > 0: - change_requests.append(ChangeRequest(service.id, changes)) - - payload_list = dumps([cr.create_payload() for cr in change_requests], default=str) - logger.info(f"Created {len(change_requests)} change requests {payload_list}") - - # Assign to attribute and return - self.change_requests = change_requests - return self.change_requests - - def send_changes(self, message_received: int, record_id: str, sequence_number: int) -> None: - """Sends change request payload off to next part of workflow - [Which at the moment is straight to the next lambda] - """ - if self.change_requests is None: - logger.error("Attempting to send change requests before get_change_requests has been called.") - return - - sqs = client("sqs") - messages = [] - for change_request in self.change_requests: - change_payload = dumps(change_request.create_payload()) - encoded = change_payload.encode() - hashed_payload = hashlib.sha256(encoded).hexdigest() - message_deduplication_id = f"{sequence_number}-{hashed_payload}" - message_group_id = change_request.service_id - entry_id = f"{change_request.service_id}-{sequence_number}" - logger.debug( - "CR to send", - extra={ - "change_request": change_payload, - "entry_id": entry_id, - "hashed_payload": f"{len(hashed_payload)} - {hashed_payload}", - "message_deduplication_id": message_deduplication_id, - "message_group_id": message_group_id, - "sequence_number": str(sequence_number), - }, - ) - - messages.append( - { - "Id": entry_id, - "MessageBody": change_payload, - "MessageDeduplicationId": message_deduplication_id, - "MessageGroupId": message_group_id, - "MessageAttributes": { - "correlation_id": {"DataType": "String", "StringValue": logger.get_correlation_id()}, - "message_received": {"DataType": "Number", "StringValue": str(message_received)}, - "dynamo_record_id": {"DataType": "String", "StringValue": record_id}, - "ods_code": {"DataType": "String", "StringValue": self.nhs_entity.odscode}, - "message_deduplication_id": {"DataType": "String", "StringValue": message_deduplication_id}, - "message_group_id": {"DataType": "String", "StringValue": message_group_id}, - }, - } - ) - if len(messages) > 0: - chunks = divide_chunks(messages, 10) - for chunk in chunks: - # TODO: Handle errors? - response = sqs.send_message_batch(QueueUrl=environ["CR_QUEUE_URL"], Entries=chunk) - logger.info("Response from sqs send_message_batch", extra={"response": response}) - logger.info(f"Sent off change payload for id={change_request.service_id}") - else: - logger.info("No changes identified") - - -@unhandled_exception_logging() -@tracer.capture_lambda_handler() -@event_source(data_class=SQSEvent) -@set_correlation_id() -@logger.inject_lambda_context -@metric_scope -def lambda_handler(event: SQSEvent, context: LambdaContext, metrics) -> None: - """Entrypoint handler for the event_processor lambda - - Args: - event (SQSEvent): Lambda function invocation event (list of 1 SQS Message) - context (LambdaContext): Lambda function context object - - Event: The event payload should contain a NHS Entity (Service) - - Some code may need to be changed if the exact input format is changed. - """ - now_ms = time_ns() // 1000000 - logger.append_keys(ods_code=None) - logger.append_keys(org_type=None) - logger.append_keys(org_sub_type=None) - logger.append_keys(dynamo_record_id=None) - logger.append_keys(message_received=None) - logger.append_keys(service_type=None) - for env_var in EXPECTED_ENVIRONMENT_VARIABLES: - if env_var not in environ: - logger.error(f"Environmental variable {env_var} not present") - return - - if len(list(event.records)) != 1: - raise Exception(f"{len(list(event.records))} records found in event. Expected 1.") - - record = next(event.records) - message = record.body - change_event = extract_body(message) - ods_code = change_event.get("ODSCode") - logger.append_keys(ods_code=ods_code) - sequence_number = get_sequence_number(record) - sqs_timestamp = int(record.attributes["SentTimestamp"]) - - s, ms = divmod(sqs_timestamp, 1000) - message_received_pretty = "%s.%03d" % (strftime("%Y-%m-%d %H:%M:%S", gmtime(s)), ms) - logger.append_keys(message_received=message_received_pretty) - logger.info("Change Event received", extra={"change-event": change_event}) - metrics.set_namespace("UEC-DOS-INT") - metrics.set_property("level", "INFO") - metrics.set_property("function_name", context.function_name) - metrics.set_property("message_received", message_received_pretty) - logger.info("Getting latest sequence number") - db_latest_sequence_number = get_latest_sequence_id_for_a_given_odscode_from_dynamodb(ods_code) - logger.info("Writing change event to dynamo") - record_id = add_change_request_to_dynamodb(change_event, sequence_number, sqs_timestamp) - correlation_id = logger.get_correlation_id() - if "broken" in correlation_id.lower(): - raise ValueError("Everything is broken boo") - metrics.set_property("correlation_id", logger.get_correlation_id()) - metrics.set_property("dynamo_record_id", record_id) - metrics.set_dimensions({"ENV": environ["ENV"]}) - metrics.put_metric("QueueToProcessorLatency", now_ms - sqs_timestamp, "Milliseconds") - logger.append_keys(dynamo_record_id=record_id) - if sequence_number is None: - logger.error("No sequence number provided, so message will be ignored.") - return - elif sequence_number < db_latest_sequence_number: - logger.error( - "Sequence id is smaller than the existing one in db for a given odscode, so will be ignored", - extra={"incoming_sequence_number": sequence_number, "db_latest_sequence_number": db_latest_sequence_number}, - ) - return - - try: - validate_event(change_event) - nhs_entity = NHSEntity(change_event) - logger.append_keys(ods_code=nhs_entity.odscode) - logger.append_keys(org_type=nhs_entity.org_type) - logger.append_keys(org_sub_type=nhs_entity.org_sub_type) - metrics.set_property("ods_code", nhs_entity.odscode) - logger.info("Created NHS Entity for processing", extra={"nhs_entity": nhs_entity}) - event_processor = EventProcessor(nhs_entity) - matching_services = event_processor.get_matching_services() - if len(matching_services) == 0: - log_unmatched_nhsuk_service(nhs_entity) - return - - if nhs_entity.is_status_hidden_or_closed(): - log_closed_or_hidden_services(nhs_entity, matching_services) - return - - if not nhs_entity.all_times_valid(): - log_invalid_open_times(nhs_entity, matching_services) - - for dos_service in matching_services: - if dos_service.any_generic_bankholiday_open_periods(): - log_service_with_generic_bank_holiday(nhs_entity, dos_service) - - event_processor.get_change_requests() - finally: - disconnect_dos_db() - - event_processor.send_changes(sqs_timestamp, record_id, sequence_number) diff --git a/application/event_processor/reporting.py b/application/event_processor/reporting.py deleted file mode 100644 index e6ec221ea..000000000 --- a/application/event_processor/reporting.py +++ /dev/null @@ -1,184 +0,0 @@ -from typing import List -from os import environ -import json - -from aws_embedded_metrics import metric_scope -from aws_lambda_powertools.logging.logger import Logger - -from common.dos import DoSService, VALID_STATUS_ID -from common.opening_times import OpenPeriod -from common.constants import ( - HIDDEN_OR_CLOSED_REPORT_ID, - INVALID_OPEN_TIMES_REPORT_ID, - INVALID_POSTCODE_REPORT_ID, - UNMATCHED_PHARMACY_REPORT_ID, - UNMATCHED_SERVICE_TYPE_REPORT_ID, - GENERIC_BANK_HOLIDAY_REPORT_ID, -) -from nhs import NHSEntity - - -logger = Logger(child=True) - - -def log_closed_or_hidden_services(nhs_entity: NHSEntity, matching_services: List[DoSService]) -> None: - """Log closed or hidden NHS UK services - - Args: - nhs_entity (NHSEntity): The NHS entity to report - matching_services (List[DoSService]): The list of DoS matching services - """ - for dos_service in matching_services: - logger.warning( - "NHS Service marked as closed or hidden, no change requests will be produced from this event", - extra={ - "report_key": HIDDEN_OR_CLOSED_REPORT_ID, - "dos_service_id": dos_service.id, - "dos_service_uid": dos_service.uid, - "nhsuk_odscode": nhs_entity.odscode, - "dos_service_publicname": dos_service.name, - "nhsuk_service_status": nhs_entity.org_status, - "nhsuk_service_type": nhs_entity.org_type, - "nhsuk_sector": nhs_entity.org_sub_type, - "dos_service_status": VALID_STATUS_ID, - "dos_service_type": dos_service.typeid, - "dos_service_type_name": dos_service.servicename, - }, - ) - - -def log_unmatched_nhsuk_service(nhs_entity: NHSEntity) -> None: - """Log unmatched NHS Services - Args: - nhs_entity (NHSEntity): NHS entity to log - """ - - logger.warning( - f"No matching DOS services found that fit all criteria for ODSCode '{nhs_entity.odscode}'", - extra={ - "report_key": UNMATCHED_PHARMACY_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "nhsuk_organisation_typeid": nhs_entity.org_type_id, - "nhsuk_organisation_status": nhs_entity.org_status, - "nhsuk_organisation_subtype": nhs_entity.org_sub_type, - "nhsuk_address1": nhs_entity.entity_data.get("Address1", ""), - "nhsuk_address2": nhs_entity.entity_data.get("Address2", ""), - "nhsuk_address3": nhs_entity.entity_data.get("Address3", ""), - "nhsuk_city": nhs_entity.entity_data.get("City", ""), - "nhsuk_county": nhs_entity.entity_data.get("County", ""), - "nhsuk_postcode": nhs_entity.postcode, - "nhsuk_parent_organisation_name": nhs_entity.parent_org_name, - }, - ) - - -@metric_scope -def log_invalid_nhsuk_postcode(nhs_entity: NHSEntity, dos_service: DoSService, metrics) -> None: - """Log invalid NHS pharmacy postcode - Args: - nhs_entity (NHSEntity): The NHS entity to report - dos_service (List[DoSService]): The list of DoS matching services - """ - error_msg = f"NHS entity '{nhs_entity.odscode}' postcode '{nhs_entity.postcode}' is not a valid DoS postcode!" - logger.warning( - error_msg, - extra={ - "report_key": INVALID_POSTCODE_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "nhsuk_organisation_type": nhs_entity.org_type, - "nhsuk_organisation_subtype": nhs_entity.org_sub_type, - "nhsuk_address1": nhs_entity.entity_data.get("Address1", ""), - "nhsuk_address2": nhs_entity.entity_data.get("Address2", ""), - "nhsuk_address3": nhs_entity.entity_data.get("Address3", ""), - "nhsuk_city": nhs_entity.entity_data.get("City", ""), - "nhsuk_county": nhs_entity.entity_data.get("County", ""), - "nhsuk_postcode": nhs_entity.postcode, - "validation_error_reason": "Postcode not valid/found on DoS", - "dos_service": dos_service.uid, - "dos_service_type_name": dos_service.servicename, - }, - ) - metrics.set_namespace("UEC-DOS-INT") - metrics.set_property("level", "WARNING") - metrics.set_property("message", error_msg) - metrics.set_property("correlation_id", logger.get_correlation_id()) - metrics.set_property("ods_code", nhs_entity.odscode) - metrics.set_dimensions({"ENV": environ["ENV"]}) - metrics.put_metric("InvalidPostcode", 1, "Count") - - -@metric_scope -def log_invalid_open_times(nhs_entity: NHSEntity, matching_services: List[DoSService], metrics) -> None: - """Report invalid open times for nhs entity - - Args: - nhs_entity (NHSEntity): The NHS entity to report - matching_services (List[DoSService]): The list of DoS matching services - """ - error_msg = f"NHS Entity '{nhs_entity.odscode}' has a misformatted or illogical set of opening times." - logger.warning( - error_msg, - extra={ - "report_key": INVALID_OPEN_TIMES_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "nhsuk_open_times_payload": json.dumps(nhs_entity.entity_data["OpeningTimes"]), - "dos_service_type_name": ", ".join(str(service.servicename) for service in matching_services), - "dos_services": ", ".join(str(service.uid) for service in matching_services), - }, - ) - metrics.set_namespace("UEC-DOS-INT") - metrics.set_property("level", "WARNING") - metrics.set_property("message", error_msg) - metrics.set_dimensions({"ENV": environ["ENV"]}) - metrics.put_metric("InvalidOpenTimes", 1, "Count") - - -def log_unmatched_service_types(nhs_entity: NHSEntity, unmatched_services: List[DoSService]) -> None: - """Log unmatched DOS service types - Args: - nhs_entity (NHSEntity): The NHS entity to report - unmatched_services (List[DoSService]): The list of DoS unmatched services - """ - for unmatched_service in unmatched_services: - logger.warning( - f"NHS entity '{nhs_entity.odscode}' service type '{ unmatched_service.typeid}' is not valid!", - extra={ - "report_key": UNMATCHED_SERVICE_TYPE_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "nhsuk_organisation_typeid": nhs_entity.org_type_id, - "nhsuk_organisation_status": nhs_entity.org_status, - "nhsuk_organisation_subtype": nhs_entity.org_sub_type, - "nhsuk_parent_organisation_name": nhs_entity.parent_org_name, - "dos_service_uid": unmatched_service.uid, - "dos_service_id": unmatched_service.id, - "dos_service_publicname": unmatched_service.name, - "dos_service_status": VALID_STATUS_ID, - "dos_service_typeid": unmatched_service.typeid, - "dos_service_type_name": unmatched_service.servicename, - }, - ) - - -def log_service_with_generic_bank_holiday(nhs_entity: NHSEntity, dos_service: DoSService) -> None: - """Log a service found to have a generic bank holiday open times set in DoS.""" - - open_periods_str = OpenPeriod.list_string(dos_service._standard_opening_times.generic_bankholiday) - - logger.warning( - f"DoS Service uid={dos_service.uid} has a generic BankHoliday Standard opening time set in DoS", - extra={ - "report_key": GENERIC_BANK_HOLIDAY_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "dos_service_uid": dos_service.uid, - "dos_service_name": dos_service.name, - "dos_service_type_id": dos_service.typeid, - "bank_holiday_opening_times": open_periods_str, - "nhsuk_parentorg": nhs_entity.parent_org_name, - "dos_service_type_name": dos_service.servicename, - }, - ) diff --git a/application/event_processor/requirements.txt b/application/event_processor/requirements.txt deleted file mode 100644 index cc0e36401..000000000 --- a/application/event_processor/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -aws-embedded-metrics -aws-lambda-powertools -psycopg2-binary diff --git a/application/event_processor/tests/STANDARD_EVENT.json b/application/event_processor/tests/STANDARD_EVENT.json deleted file mode 100644 index ee4f3d9f2..000000000 --- a/application/event_processor/tests/STANDARD_EVENT.json +++ /dev/null @@ -1,182 +0,0 @@ -{ - "ODSCode": "TES73", - "OrganisationName": "Fake Pharmacy", - "OrganisationTypeId": "PHA", - "OrganisationType": "Pharmacy", - "OrganisationSubType": "Community", - "OrganisationStatus": "Visible", - "Address1": "Flat 619", - "Address2": "62 Fake Street", - "Address3": "Hazel Grove", - "City": "Bath", - "County": "Somerset", - "Postcode": "TE5 7ER", - "ParentOrganisation": { - "ODSCode": "TES", - "OrganisationName": "Fake Pharmacy Corperation" - }, - "Contacts": [{ - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": "http://www.FakePharmacy.co.uk/" - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": "01234 567890" - } - ], - "OpeningTimes": [{ - "Weekday": "Monday", - "OpeningTime": "09:00", - "ClosingTime": "13:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Monday", - "OpeningTime": "14:00", - "ClosingTime": "17:30", - "OffsetOpeningTime": 840, - "OffsetClosingTime": 1050, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Tuesday", - "OpeningTime": "09:00", - "ClosingTime": "13:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Tuesday", - "OpeningTime": "14:00", - "ClosingTime": "17:30", - "OffsetOpeningTime": 840, - "OffsetClosingTime": 1050, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Wednesday", - "OpeningTime": "09:00", - "ClosingTime": "13:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Wednesday", - "OpeningTime": "14:00", - "ClosingTime": "17:30", - "OffsetOpeningTime": 840, - "OffsetClosingTime": 1050, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Thursday", - "OpeningTime": "09:00", - "ClosingTime": "13:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Thursday", - "OpeningTime": "14:00", - "ClosingTime": "17:30", - "OffsetOpeningTime": 840, - "OffsetClosingTime": 1050, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Friday", - "OpeningTime": "09:00", - "ClosingTime": "13:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Friday", - "OpeningTime": "14:00", - "ClosingTime": "17:30", - "OffsetOpeningTime": 840, - "OffsetClosingTime": 1050, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Saturday", - "OpeningTime": "09:00", - "ClosingTime": "13:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 720, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "", - "OpeningTime": "", - "ClosingTime": "", - "OffsetOpeningTime": 0, - "OffsetClosingTime": 0, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Dec 24 2021", - "IsOpen": false - }, - { - "Weekday": "", - "OpeningTime": "", - "ClosingTime": "", - "OffsetOpeningTime": 0, - "OffsetClosingTime": 0, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Dec 25 2021", - "IsOpen": false - }, - { - "Weekday": "", - "OpeningTime": "", - "ClosingTime": "", - "OffsetOpeningTime": 0, - "OffsetClosingTime": 0, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Dec 26 2021", - "IsOpen": false - }, - { - "Weekday": "", - "OpeningTime": "", - "ClosingTime": "", - "OffsetOpeningTime": 0, - "OffsetClosingTime": 0, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Dec 27 2021", - "IsOpen": false - } - ] -} diff --git a/application/event_processor/tests/__init__.py b/application/event_processor/tests/__init__.py deleted file mode 100644 index 1b407198f..000000000 --- a/application/event_processor/tests/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from sys import modules -from importlib import import_module - -modules["change_request"] = import_module("event_processor.change_request") -modules["opening_times"] = import_module("common.opening_times") -modules["nhs"] = import_module("event_processor.nhs") -modules["dos"] = import_module("common.dos") -modules["reporting"] = import_module("event_processor.reporting") -modules["change_event_exceptions"] = import_module("common.change_event_exceptions") -modules["change_event_validation"] = import_module("event_processor.change_event_validation") -modules["changes"] = import_module("event_processor.changes") diff --git a/application/event_processor/tests/conftest.py b/application/event_processor/tests/conftest.py deleted file mode 100644 index 9563adb20..000000000 --- a/application/event_processor/tests/conftest.py +++ /dev/null @@ -1,23 +0,0 @@ -import os -import json -from pathlib import Path - -from pytest import fixture -from testfixtures import LogCapture - - -std_event_path = os.path.join(Path(__file__).parent.resolve(), "STANDARD_EVENT.json") -with open(std_event_path, "r") as file: - PHARMACY_STANDARD_EVENT = json.load(file) - - -@fixture() -def log_capture(): - with LogCapture(names="lambda") as capture: - yield capture - - -@fixture -def change_event(): - change_event = PHARMACY_STANDARD_EVENT.copy() - yield change_event diff --git a/application/event_processor/tests/test_change_event_validation.py b/application/event_processor/tests/test_change_event_validation.py deleted file mode 100644 index b722633f8..000000000 --- a/application/event_processor/tests/test_change_event_validation.py +++ /dev/null @@ -1,56 +0,0 @@ -from unittest.mock import patch - -import pytest -from pytest import raises - -from ..change_event_validation import ValidationException, check_ods_code_length, validate_event - -FILE_PATH = "application.event_processor.change_event_validation" - - -@patch(f"{FILE_PATH}.validate_organisation_keys") -def test_validate_event(mock_validate_organisation_keys, change_event): - # Act & Assert - validate_event(change_event) - - -@patch(f"{FILE_PATH}.validate_organisation_keys") -@patch(f"{FILE_PATH}.check_ods_code_length") -def test_validate_event_missing_key(mock_check_ods_code_length, mock_validate_organisation_keys, change_event): - # Arrange - del change_event["ODSCode"] - # Act - with raises(ValidationException): - validate_event(change_event) - # Assert - mock_check_ods_code_length.assert_not_called() - mock_validate_organisation_keys.assert_not_called() - - -@pytest.mark.parametrize( - "odscode, odscode_length", - [ - ("FXXX1", 5), - ("AAAAA", 5), - ("00000", 5), - ("V001234", 7), - ], -) -def test_check_ods_code_length(odscode, odscode_length): - # Act & Assert - check_ods_code_length(odscode, odscode_length) - - -@pytest.mark.parametrize( - "odscode, odscode_length", - [ - ("FXXX11", 5), - ("AAAA", 5), - ("V0345", 7), - ("V01234567", 7), - ], -) -def test_check_ods_code_length_incorrect_length(odscode, odscode_length): - # Act & Assert - with raises(ValidationException): - check_ods_code_length(odscode, odscode_length) diff --git a/application/event_processor/tests/test_change_request.py b/application/event_processor/tests/test_change_request.py deleted file mode 100644 index 34ac28fe3..000000000 --- a/application/event_processor/tests/test_change_request.py +++ /dev/null @@ -1,49 +0,0 @@ -from ..change_request import ChangeRequest -from aws_lambda_powertools import Logger - -logger = Logger() - - -class TestChangeRequest: - service_id = 123456789 - correlation_id = "dummy_correlation_id" - changes = {"website": "https://test.com"} - - def test__init__(self): - # Arrange - expected_change_requests = { - "reference": str(self.correlation_id), - "system": "DoS Integration", - "message": f"DoS Integration CR. correlation-id: {self.correlation_id}", - "replace_opening_dates_mode": True, - "service_id": str(self.service_id), - "changes": self.changes, - } - logger.set_correlation_id(self.correlation_id) - # Act - change_request = ChangeRequest(self.service_id, self.changes) - # Assert - assert expected_change_requests == { - "reference": change_request.reference, - "system": change_request.system, - "message": change_request.message, - "replace_opening_dates_mode": change_request.replace_opening_dates_mode, - "service_id": str(change_request.service_id), - "changes": change_request.changes, - } - - def test_get_change_request(self): - # Arrange - expected_change_requests = { - "reference": str(self.correlation_id), - "system": "DoS Integration", - "message": f"DoS Integration CR. correlation-id: {self.correlation_id}", - "replace_opening_dates_mode": True, - "service_id": str(self.service_id), - "changes": self.changes, - } - logger.set_correlation_id(self.correlation_id) - # Act - change_request = ChangeRequest(self.service_id, self.changes) - # Assert - assert expected_change_requests == change_request.create_payload() diff --git a/application/event_processor/tests/test_changes.py b/application/event_processor/tests/test_changes.py deleted file mode 100644 index 7605140a0..000000000 --- a/application/event_processor/tests/test_changes.py +++ /dev/null @@ -1,573 +0,0 @@ -from os import environ -from unittest.mock import patch - -import pytest - -from common.tests.conftest import dummy_dos_location, dummy_dos_service -from common.dos import dos_location_cache -from ..nhs import NHSEntity -from ..change_request import ( - ADDRESS_CHANGE_KEY, - ADDRESS_LINES_KEY, - OPENING_DATES_KEY, - OPENING_DAYS_KEY, - PHONE_CHANGE_KEY, - POSTCODE_CHANGE_KEY, - PUBLICNAME_CHANGE_KEY, - WEBSITE_CHANGE_KEY, -) -from ..changes import ( - get_changes, - update_changes, - update_changes_with_opening_times, - update_changes_with_address_and_postcode, -) - - -FILE_PATH = "application.event_processor.changes" - - -def test_get_changes_same_data(): - # Act - dos_service = dummy_dos_service() - nhs_entity = NHSEntity( - { - "Postcode": dos_service.postcode, - "Phone": dos_service.publicphone, - "OrganisationName": dos_service.publicname, - "Address1": dos_service.address, - "Contacts": [ - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": dos_service.web, - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": dos_service.publicphone, - }, - ], - "OpeningTimes": [], - } - ) - # Act - response = get_changes(dos_service, nhs_entity) - # Assert - assert {} == response, f"Should return empty dict, actually: {response}" - - -def test_get_changes_different_changes(): - # Arrange - website = "changed-website.com" - postcode = "TA1 TA1" - phone = "0123456789" - organisation_name = "changed-organisation-name" - address1 = "changed-address1" - address2 = "changed-address2" - address3 = "changed-address3" - city = "changed-city" - county = "changed-county" - - nhs_entity = NHSEntity( - { - "Postcode": postcode, - "OrganisationName": organisation_name, - "Address1": address1, - "Address2": address2, - "Address3": address3, - "City": city, - "County": county, - "Contacts": [ - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": website, - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": phone, - }, - ], - "OpeningTimes": [], - } - ) - - dos_service = dummy_dos_service() - dos_location = dummy_dos_location() - dos_location.postcode = postcode - dos_location_cache[dos_location.normal_postcode()] = [dos_location] - - expected_changes = { - ADDRESS_CHANGE_KEY: { - ADDRESS_LINES_KEY: [address1, address2, address3, city, county], - POSTCODE_CHANGE_KEY: nhs_entity.postcode, - }, - PUBLICNAME_CHANGE_KEY: organisation_name, - WEBSITE_CHANGE_KEY: website, - PHONE_CHANGE_KEY: phone, - } - # Act - response = get_changes(dos_service, nhs_entity) - # Assert - assert expected_changes == response, f"Should return {expected_changes} dict, actually: {response}" - - -def test_update_changes_publicphone_to_change_request_if_not_equal_is_equal(): - # Arrange - changes = {} - # Act - update_changes(changes, PHONE_CHANGE_KEY, "000000000", "000000000") - update_changes(changes, PUBLICNAME_CHANGE_KEY, "boots", "boots") - update_changes(changes, WEBSITE_CHANGE_KEY, "www.wow.co.uk", "www.wow.co.uk") - # Assert - assert changes == {}, f"Should return empty dict, actually: {changes}" - - -@pytest.mark.parametrize( - "dos_val, nhs_val,expected", - [ - ("www.test1.com", "www.test2.com", {"website": "www.test2.com"}), - ("", "www.test2.com", {"website": "www.test2.com"}), - (None, "www.test2.com", {"website": "www.test2.com"}), - ("www.test2.com", None, {"website": ""}), - ("www.test2.com", "", {"website": ""}), - ("www.test2.com", "www.test2.com", {}), - ("", None, {}), - (None, "", {}), - ("", " ", {}), - (None, None, {}), - ], -) -def test_update_changes_for_website(dos_val, nhs_val, expected): - # Arrange - changes = {} - # Act - update_changes(changes, WEBSITE_CHANGE_KEY, dos_val, nhs_val) - assert changes == expected, f"Should return {expected}, actually: {changes}" - - -@pytest.mark.parametrize( - "dos_val, nhs_val,expected", - [ - ("test1", "test2", {"public_name": "test2"}), - ("", "test2", {"public_name": "test2"}), - (None, "test2", {"public_name": "test2"}), - ("test2", None, {"public_name": ""}), - ("test2", "", {"public_name": ""}), - ("test2", "test2", {}), - ("", None, {}), - (None, "", {}), - ("", " ", {}), - (None, None, {}), - ], -) -def test_update_changes_for_publicname(dos_val, nhs_val, expected): - # Arrange - changes = {} - # Act - update_changes(changes, PUBLICNAME_CHANGE_KEY, dos_val, nhs_val) - assert changes == expected, f"Should return {expected}, actually: {changes}" - - -@pytest.mark.parametrize( - "dos_val, nhs_val,expected", - [ - ("000000000", "123456789", {"phone": "123456789"}), - ("", "123456789", {"phone": "123456789"}), - (None, "123456789", {"phone": "123456789"}), - ("123456789", None, {"phone": ""}), - ("123456789", "", {"phone": ""}), - ("123456789", "123456789", {}), - ("", None, {}), - (None, "", {}), - ("", " ", {}), - (None, None, {}), - ], -) -def test_update_changes_for_phone(dos_val, nhs_val, expected): - # Arrange - changes = {} - # Act - update_changes(changes, PHONE_CHANGE_KEY, dos_val, nhs_val) - assert changes == expected, f"Should return {expected}, actually: {changes}" - - -def test_update_changes_publicphone_to_change_request_if_not_equal_not_equal(): - # Arrange - changes = {} - nhs_uk_phone = "000000000" - dos_public_phone = "123456789" - expected_changes = {"publicphone": nhs_uk_phone} - # Act - update_changes(changes, "publicphone", dos_public_phone, nhs_uk_phone) - # Assert - assert changes == expected_changes, f"Should return {expected_changes} dict, actually: {changes}" - - -@patch(f"{FILE_PATH}.get_valid_dos_postcode") -def test_do_not_update_address_if_postcode_invalid(mock_get_valid_dos_postcode, change_event): - # Arrange - environ["ENV"] = "test" - nhs_entity = NHSEntity(change_event) - dos_service = dummy_dos_service() - mock_get_valid_dos_postcode.return_value = None - existing_changes = {ADDRESS_CHANGE_KEY: ["address1", "address2", "address3", "city", "county"]} - # Act - update_changes_with_address_and_postcode(existing_changes, dos_service, nhs_entity) - # Assert - mock_get_valid_dos_postcode.assert_called_once_with(nhs_entity.normal_postcode()) - assert existing_changes == {}, f"Should return empty dict, actually: {existing_changes}" - - -@patch(f"{FILE_PATH}.get_valid_dos_postcode") -def test_do_not_update_address_if_postcode_invalid_no_address(mock_get_valid_dos_postcode, change_event): - # Arrange - environ["ENV"] = "test" - nhs_entity = NHSEntity(change_event) - dos_service = dummy_dos_service() - mock_get_valid_dos_postcode.return_value = None - existing_changes = {} - # Act - update_changes_with_address_and_postcode(existing_changes, dos_service, nhs_entity) - # Assert - mock_get_valid_dos_postcode.assert_called_once_with(nhs_entity.normal_postcode()) - assert existing_changes == {}, f"Should return empty dict, actually: {existing_changes}" - - -@patch(f"{FILE_PATH}.logger") -@patch(f"{FILE_PATH}.get_valid_dos_postcode") -def test_update_changes_with_address_and_postcode_if_address_is_equal_but_not_postcode( - mock_get_valid_dos_postcode, mock_logger -): - # Arrange - changes = {} - - nhs_uk_entity = NHSEntity({}) - nhs_uk_entity.address_lines = ["address1" "address2" "address3" "city" "county"] - nhs_uk_entity.postcode = "TA2 TA2" - - dos_service = dummy_dos_service() - dos_service.address = "$".join(nhs_uk_entity.address_lines) - dos_service.postcode = "TA1 TA1" - - dos_postcode = dos_service.normal_postcode() - nhs_postcode = nhs_uk_entity.normal_postcode() - mock_get_valid_dos_postcode.return_value = "TA2TA2" - expected_changes = { - "address": { - "address_lines": [ - "address1address2address3citycounty", - ], - "post_code": "TA2TA2", - } - } - - # Act - update_changes_with_address_and_postcode(changes, dos_service, nhs_uk_entity) - # Assert - mock_logger.debug.assert_called_with( - f"Address is equal but Postcode is not equal, {dos_postcode=} != {nhs_postcode=}" - ) - assert expected_changes == changes, f"Should return {expected_changes} dict, actually: {changes}" - - -@patch(f"{FILE_PATH}.logger") -@patch(f"{FILE_PATH}.get_valid_dos_postcode") -def test_not_update_changes_with_address_and_postcode_to_change_request_if_address_and_postcode_is_equal( - mock_get_valid_dos_postcode, mock_logger -): - # Arrange - changes = {} - - nhs_uk_entity = NHSEntity({}) - nhs_uk_entity.address_lines = ["address1" "address2" "address3" "city" "county"] - nhs_uk_entity.postcode = "TA2 TA2" - - dos_service = dummy_dos_service() - dos_service.address = "$".join(nhs_uk_entity.address_lines) - dos_service.postcode = "TA2 TA2" - mock_get_valid_dos_postcode.return_value = "TA2 TA2" - expected_changes = {} - - # Act - update_changes_with_address_and_postcode(changes, dos_service, nhs_uk_entity) - # Assert - assert expected_changes == changes, f"Should return {expected_changes} dict, actually: {changes}" - - -def test_update_changes_with_opening_times(): - # Arrange - nhs_uk_entity = NHSEntity( - { - "OpeningTimes": [ - { - "Weekday": "Monday", - "OpeningTime": "09:00", - "ClosingTime": "12:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Monday", - "OpeningTime": "13:00", - "ClosingTime": "16:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Monday", - "OpeningTime": "17:00", - "ClosingTime": "18:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Tuesday", - "OpeningTime": "09:00", - "ClosingTime": "20:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Wednesday", - "OpeningTime": "09:00", - "ClosingTime": "20:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Thursday", - "OpeningTime": "09:00", - "ClosingTime": "20:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Friday", - "OpeningTime": "09:00", - "ClosingTime": "20:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Saturday", - "OpeningTime": "09:00", - "ClosingTime": "12:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Saturday", - "OpeningTime": "13:00", - "ClosingTime": "18:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Sunday", - "OpeningTime": None, - "ClosingTime": None, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": False, - }, - { - "Weekday": "", - "OpeningTime": "08:00", - "ClosingTime": "12:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Apr 15 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "13:00", - "ClosingTime": "16:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Apr 15 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "07:00", - "ClosingTime": "11:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Apr 18 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "12:00", - "ClosingTime": "15:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Apr 18 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "16:00", - "ClosingTime": "18:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Apr 18 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": None, - "ClosingTime": None, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Feb 21 2022", - "IsOpen": False, - }, - { - "Weekday": "", - "OpeningTime": None, - "ClosingTime": None, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 7 2022", - "IsOpen": False, - }, - { - "Weekday": "", - "OpeningTime": None, - "ClosingTime": None, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 8 2022", - "IsOpen": False, - }, - { - "Weekday": "", - "OpeningTime": "07:00", - "ClosingTime": "12:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 10 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "13:00", - "ClosingTime": "17:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 10 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "18:00", - "ClosingTime": "20:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 10 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "07:00", - "ClosingTime": "12:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 11 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "13:00", - "ClosingTime": "17:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 11 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "18:00", - "ClosingTime": "20:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 11 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "06:00", - "ClosingTime": "12:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 19 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": "13:00", - "ClosingTime": "18:00", - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 19 2022", - "IsOpen": True, - }, - { - "Weekday": "", - "OpeningTime": None, - "ClosingTime": None, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Mar 9 2022", - "IsOpen": False, - }, - ], - } - ) - - expected_changes = { - OPENING_DATES_KEY: { - "2022-04-15": [{"start_time": "08:00", "end_time": "12:00"}, {"start_time": "13:00", "end_time": "16:00"}], - "2022-04-18": [ - {"start_time": "07:00", "end_time": "11:00"}, - {"start_time": "12:00", "end_time": "15:00"}, - {"start_time": "16:00", "end_time": "18:00"}, - ], - "2022-02-21": [], - "2022-03-07": [], - "2022-03-08": [], - "2022-03-10": [ - {"start_time": "07:00", "end_time": "12:00"}, - {"start_time": "13:00", "end_time": "17:00"}, - {"start_time": "18:00", "end_time": "20:00"}, - ], - "2022-03-11": [ - {"start_time": "07:00", "end_time": "12:00"}, - {"start_time": "13:00", "end_time": "17:00"}, - {"start_time": "18:00", "end_time": "20:00"}, - ], - "2022-03-19": [{"start_time": "06:00", "end_time": "12:00"}, {"start_time": "13:00", "end_time": "18:00"}], - "2022-03-09": [], - }, - OPENING_DAYS_KEY: { - "Monday": [ - {"start_time": "09:00", "end_time": "12:00"}, - {"start_time": "13:00", "end_time": "16:00"}, - {"start_time": "17:00", "end_time": "18:00"}, - ], - "Tuesday": [{"start_time": "09:00", "end_time": "20:00"}], - "Wednesday": [{"start_time": "09:00", "end_time": "20:00"}], - "Thursday": [{"start_time": "09:00", "end_time": "20:00"}], - "Friday": [{"start_time": "09:00", "end_time": "20:00"}], - "Saturday": [{"start_time": "09:00", "end_time": "12:00"}, {"start_time": "13:00", "end_time": "18:00"}], - "Sunday": [], - }, - } - dos_service = dummy_dos_service() - # Act - changes = {} - update_changes_with_opening_times(changes, dos_service, nhs_uk_entity) - print(changes) - # Assert - assert expected_changes == changes, f"Should return {expected_changes} dict, actually: {changes}" diff --git a/application/event_processor/tests/test_event_processor.py b/application/event_processor/tests/test_event_processor.py deleted file mode 100644 index 574624509..000000000 --- a/application/event_processor/tests/test_event_processor.py +++ /dev/null @@ -1,719 +0,0 @@ -import hashlib -import logging -from dataclasses import dataclass -from json import dumps -from os import environ -from random import choices -from unittest.mock import patch - -from aws_embedded_metrics.logger.metrics_logger import MetricsLogger -from aws_lambda_powertools import Logger -from common.dos import dos_location_cache -from pytest import fixture, raises - -from ..change_request import ( - ADDRESS_CHANGE_KEY, - ADDRESS_LINES_KEY, - PHONE_CHANGE_KEY, - POSTCODE_CHANGE_KEY, - PUBLICNAME_CHANGE_KEY, - WEBSITE_CHANGE_KEY, - ChangeRequest, -) -from ..event_processor import EXPECTED_ENVIRONMENT_VARIABLES, EventProcessor, lambda_handler -from ..nhs import NHSEntity -from common.tests.conftest import dummy_dos_location, dummy_dos_service - -FILE_PATH = "application.event_processor.event_processor" - - -@fixture -def mock_metric_logger(): - InvocationTracker.reset() - - async def flush(self): - print("flush called") - InvocationTracker.record() - - MetricsLogger.flush = flush - - -@fixture -def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "event-processor" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:event-processor" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" - - return LambdaContext() - - -def test__init__(): - # Arrange - test_data = {} - for i in range(10): - random_str = "".join(choices("ABCDEFGHIJKLM", k=8)) - test_data[random_str] = random_str - test_data["OpeningTimes"] = [ - { - "Weekday": "Friday", - "OpeningTime": "08:45", - "ClosingTime": "17:00", - "OffsetOpeningTime": 525, - "OffsetClosingTime": 1020, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Friday", - "OpeningTime": "08:45", - "ClosingTime": "17:00", - "OpeningTimeType": "Surgery", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - ] - nhs_entity = NHSEntity(test_data) - # Act - event_processor = EventProcessor(nhs_entity) - # Assert - assert event_processor.nhs_entity == nhs_entity - assert isinstance(event_processor.matching_services, type(None)) - assert isinstance(event_processor.change_requests, type(None)) - assert event_processor.matching_services is None - assert event_processor.change_requests is None - - -def test_get_change_requests_full_change_request(): - # Arrange - service_1 = dummy_dos_service() - service_1.id = 1 - service_1.uid = 101 - service_1.odscode = "SLC4501" - service_1.web = "www.fakesite.com" - service_1.publicphone = "01462622435" - service_1.postcode = "S45 1AB" - - nhs_entity = NHSEntity({}) - nhs_entity.odscode = "SLC45" - nhs_entity.website = "www.site.com" - nhs_entity.phone = "01462622435" - nhs_entity.postcode = "S45 1AA" - nhs_entity.org_name = "Fake NHS Service" - nhs_entity.address_lines = ["Fake Street1", "Fake Street2", "Fake Street3", "Fake City", "Fake County"] - nhs_entity.OpeningTimes = [] - - event_processor = EventProcessor(nhs_entity) - event_processor.matching_services = [service_1] - - dos_location = dummy_dos_location() - dos_location.postcode = nhs_entity.postcode - dos_location_cache[dos_location.normal_postcode()] = [dos_location] - - # Act - change_requests = event_processor.get_change_requests() - # Assert - assert ( - len(change_requests) == 1 - ), f"Should have 1 change request but more found: {len(change_requests)} change requests" - - cr = change_requests[0] - for field in ["system", "service_id", "changes"]: - assert hasattr(cr, field), f"Attribute {field} not found in change request" - - assert cr.system == "DoS Integration", f"System should be DoS Integration but is {cr.system}" - - expected_changes = { - WEBSITE_CHANGE_KEY: nhs_entity.website, - PUBLICNAME_CHANGE_KEY: nhs_entity.org_name, - ADDRESS_CHANGE_KEY: { - ADDRESS_LINES_KEY: nhs_entity.address_lines, - POSTCODE_CHANGE_KEY: nhs_entity.postcode, - }, - } - assert cr.changes == expected_changes, f"Changes should be {expected_changes} but they are {cr.changes}" - - -@patch.object(Logger, "error") -def test_get_change_requests_when_no_matching_services(mock_logger): - # Arrange - nhs_entity = NHSEntity({}) - nhs_entity.odscode = "SLC45" - nhs_entity.website = "www.site.com" - nhs_entity.phone = "01462622435" - nhs_entity.postcode = "S45 1AA" - nhs_entity.org_name = "Fake NHS Service" - nhs_entity.address_lines = ["Fake Street1", "Fake Street2", "Fake Street3", "Fake City", "Fake County"] - nhs_entity.OpeningTimes = [] - - event_processor = EventProcessor(nhs_entity) - event_processor.matching_services = None - # Act - event_processor.get_change_requests() - # Assert - mock_logger.assert_called_with("Attempting to form change requests before matching services have been found.") - - -@patch(f"{FILE_PATH}.get_matching_dos_services") -@patch(f"{FILE_PATH}.log_unmatched_service_types") -def test_get_matching_services(mock_log_unmatched_service_types, mock_get_matching_dos_services, change_event): - # Arrange - nhs_entity = NHSEntity(change_event) - service = dummy_dos_service() - service.typeid = 13 - service.statusid = 1 - mock_get_matching_dos_services.return_value = [service] - event_processor = EventProcessor(nhs_entity) - # Act - matching_services = event_processor.get_matching_services() - # Assert - assert matching_services == [service] - - mock_log_unmatched_service_types.assert_not_called() - - -@patch(f"{FILE_PATH}.get_matching_dos_services") -@patch(f"{FILE_PATH}.log_unmatched_service_types") -def test_get_unmatching_services(mock_log_unmatched_service_types, mock_get_matching_dos_services, change_event): - # Arrange - nhs_entity = NHSEntity(change_event) - service = dummy_dos_service() - service.typeid = 999 - service.statusid = 1 - mock_get_matching_dos_services.return_value = [service] - event_processor = EventProcessor(nhs_entity) - # Act - event_processor.get_matching_services() - # Assert - mock_log_unmatched_service_types.assert_called_once() - - -def get_message_attributes( - correlation_id: str, - message_received: int, - record_id: str, - ods_code: str, - message_deduplication_id: str, - message_group_id: str, -): - return { - "correlation_id": {"DataType": "String", "StringValue": correlation_id}, - "message_received": {"DataType": "Number", "StringValue": str(message_received)}, - "dynamo_record_id": {"DataType": "String", "StringValue": record_id}, - "ods_code": {"DataType": "String", "StringValue": ods_code}, - "message_deduplication_id": {"DataType": "String", "StringValue": message_deduplication_id}, - "message_group_id": {"DataType": "String", "StringValue": message_group_id}, - } - - -@patch.object(Logger, "get_correlation_id", return_value="1") -@patch.object(Logger, "info") -@patch(f"{FILE_PATH}.client") -def test_send_changes(mock_client, mock_logger, get_correlation_id_mock): - # Arrange - q_name = "test" - environ["CR_QUEUE_URL"] = q_name - change_request = ChangeRequest(service_id=49016) - change_request.reference = "1" - change_request.system = "Profile Updater (test)" - change_request.message = "Test message 1531816592293|@./" - change_request.changes = { - PHONE_CHANGE_KEY: "0118 999 88199 9119 725 3", - WEBSITE_CHANGE_KEY: "https://www.google.pl", - } - record_id = "someid" - message_received = 1642501355616 - nhs_entity = NHSEntity({}) - nhs_entity.odscode = "SLC45" - nhs_entity.website = "www.site.com" - nhs_entity.phone = "01462622435" - nhs_entity.postcode = "S45 1AA" - nhs_entity.org_name = "Fake NHS Service" - nhs_entity.org_type_id = "PHA" - nhs_entity.address_lines = ["Fake Street1", "Fake Street2", "Fake Street3", "Fake City", "Fake County"] - sequence_number = 1 - - event_processor = EventProcessor(nhs_entity) - event_processor.change_requests = [change_request] - # Act - event_processor.send_changes(message_received, record_id, sequence_number) - # Assert - mock_client.assert_called_with("sqs") - change_payload = dumps(change_request.create_payload()) - encoded = change_payload.encode() - hashed_payload = hashlib.sha256(encoded).hexdigest() - entry_details = { - "Id": "49016-1", - "MessageBody": change_payload, - "MessageDeduplicationId": f"1-{hashed_payload}", - "MessageGroupId": "49016", - "MessageAttributes": get_message_attributes( - "1", message_received, record_id, nhs_entity.odscode, f"1-{hashed_payload}", "49016" - ), - } - mock_client.return_value.send_message_batch.assert_called_with( - QueueUrl=q_name, - Entries=[entry_details], - ) - mock_logger.assert_called_with(f"Sent off change payload for id={change_request.service_id}") - # Clean up - del environ["CR_QUEUE_URL"] - - -@patch.object(Logger, "error") -@patch(f"{FILE_PATH}.client") -def test_send_changes_when_get_change_requests_not_run(mock_client, mock_logger): - # Arrange - record_id = "someid" - message_received = 1642501355616 - - nhs_entity = NHSEntity({}) - nhs_entity.odscode = "SLC45" - nhs_entity.website = "www.site.com" - nhs_entity.phone = "01462622435" - nhs_entity.postcode = "S45 1AA" - nhs_entity.org_name = "Fake NHS Service" - nhs_entity.address_lines = ["Fake Street1", "Fake Street2", "Fake Street3", "Fake City", "Fake County"] - sequence_number = 1 - event_processor = EventProcessor(nhs_entity) - event_processor.change_requests = None - # Act - event_processor.send_changes(message_received, record_id, sequence_number) - # Assert - mock_logger.assert_called_with("Attempting to send change requests before get_change_requests has been called.") - - -@patch.object(Logger, "info") -@patch(f"{FILE_PATH}.client") -def test_send_changes_when_no_change_requests(mock_client, mock_logger): - # Arrange - record_id = "someid" - message_received = 1642501355616 - nhs_entity = NHSEntity({}) - - nhs_entity.odscode = "SLC45" - nhs_entity.website = "www.site.com" - nhs_entity.phone = "01462622435" - nhs_entity.postcode = "S45 1AA" - nhs_entity.org_name = "Fake NHS Service" - nhs_entity.address_lines = ["Fake Street1", "Fake Street2", "Fake Street3", "Fake City", "Fake County"] - sequence_number = 1 - event_processor = EventProcessor(nhs_entity) - event_processor.change_requests = [] - # Act - event_processor.send_changes(message_received, record_id, sequence_number) - # Assert - mock_logger.assert_called_with("No changes identified") - mock_client.assert_called_with("sqs") - mock_client.return_value.send_message_batch.assert_not_called() - - -@patch(f"{FILE_PATH}.validate_event") -@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") -@patch(f"{FILE_PATH}.add_change_request_to_dynamodb") -@patch(f"{FILE_PATH}.EventProcessor") -@patch(f"{FILE_PATH}.NHSEntity") -@patch(f"{FILE_PATH}.extract_body") -@patch(f"{FILE_PATH}.time_ns", return_value=1642619746522500523) -@patch.object(MetricsLogger, "put_metric") -@patch.object(MetricsLogger, "set_dimensions") -def test_lambda_handler_unmatched_service( - mock_set_dimension, - mock_put_metric, - mock_time, - mock_extract_body, - mock_nhs_entity, - mock_event_processor, - mock_add_change_request_to_dynamodb, - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb, - mock_validate_event, - change_event, - lambda_context, - mock_metric_logger, -): - # Arrange - mock_entity = NHSEntity(change_event) - sqs_event = SQS_EVENT.copy() - environ["ENV"] = "test" - sqs_event["Records"][0]["body"] = dumps(change_event) - mock_extract_body.return_value = change_event - mock_nhs_entity.return_value = mock_entity - mock_add_change_request_to_dynamodb.return_value = None - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 0 - for env in EXPECTED_ENVIRONMENT_VARIABLES: - environ[env] = "test" - # Act - response = lambda_handler(sqs_event, lambda_context) - # Assert - assert response is None, f"Response should be None but is {response}" - mock_extract_body.assert_called_once_with(sqs_event["Records"][0]["body"]) - mock_nhs_entity.assert_called_once_with(change_event) - mock_event_processor.assert_called_once_with(mock_entity) - mock_event_processor.send_changes.assert_not_called() - mock_set_dimension.assert_called_once_with({"ENV": "test"}) - - mock_put_metric.assert_called_with("QueueToProcessorLatency", 3000, "Milliseconds") - # Clean up - for env in EXPECTED_ENVIRONMENT_VARIABLES: - del environ[env] - - -@patch(f"{FILE_PATH}.validate_event") -@patch(f"{FILE_PATH}.disconnect_dos_db") -@patch(f"{FILE_PATH}.log_unmatched_nhsuk_service") -@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") -@patch(f"{FILE_PATH}.add_change_request_to_dynamodb") -@patch(f"{FILE_PATH}.EventProcessor") -@patch(f"{FILE_PATH}.NHSEntity") -@patch(f"{FILE_PATH}.extract_body") -def test_lambda_handler_no_matching_dos_services( - mock_extract_body, - mock_nhs_entity, - mock_event_processor, - mock_add_change_request_to_dynamodb, - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb, - mock_log_unmatched_nhsuk_service, - mock_disconnect_dos_db, - mock_validate_event, - change_event, - lambda_context, -): - # Arrange - mock_entity = NHSEntity(change_event) - sqs_event = SQS_EVENT.copy() - sqs_event["Records"][0]["body"] = dumps(change_event) - mock_extract_body.return_value = change_event - mock_nhs_entity.return_value = mock_entity - mock_add_change_request_to_dynamodb.return_value = 1 - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 0 - mock_event_processor.return_value.get_matching_services.return_value = [] - for env in EXPECTED_ENVIRONMENT_VARIABLES: - environ[env] = "test" - # Act - lambda_handler(sqs_event, lambda_context) - # Assert - mock_log_unmatched_nhsuk_service.assert_called_once() - mock_event_processor.get_change_requests.assert_not_called() - mock_disconnect_dos_db.assert_called_once() - mock_event_processor.send_changes.assert_not_called() - - # Clean up - for env in EXPECTED_ENVIRONMENT_VARIABLES: - del environ[env] - - -@patch(f"{FILE_PATH}.validate_event") -@patch(f"{FILE_PATH}.disconnect_dos_db") -@patch(f"{FILE_PATH}.log_closed_or_hidden_services") -@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") -@patch(f"{FILE_PATH}.add_change_request_to_dynamodb") -@patch(f"{FILE_PATH}.EventProcessor") -@patch(f"{FILE_PATH}.NHSEntity") -@patch(f"{FILE_PATH}.extract_body") -def test_lambda_handler_hidden_or_closed_pharmacies( - mock_extract_body, - mock_nhs_entity, - mock_event_processor, - mock_add_change_request_to_dynamodb, - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb, - mock_log_closed_or_hidden_services, - mock_disconnect_dos_db, - mock_validate_event, - change_event, - lambda_context, -): - # Arrange - service = dummy_dos_service() - service.id = 1 - service.uid = 101 - service.odscode = "SLC4501" - service.web = "www.fakesite.com" - service.publicphone = "01462622435" - service.postcode = "S45 1AB" - - change_event["OrganisationStatus"] = "closed" - mock_entity = NHSEntity(change_event) - sqs_event = SQS_EVENT.copy() - sqs_event["Records"][0]["body"] = dumps(change_event) - mock_extract_body.return_value = change_event - mock_nhs_entity.return_value = mock_entity - mock_add_change_request_to_dynamodb.return_value = 1 - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 0 - mock_event_processor.return_value.get_matching_services.return_value = [service] - - for env in EXPECTED_ENVIRONMENT_VARIABLES: - environ[env] = "test" - # Act - lambda_handler(sqs_event, lambda_context) - # Assert - mock_log_closed_or_hidden_services.assert_called_once() - mock_disconnect_dos_db.assert_called_once() - mock_event_processor.get_change_requests.assert_not_called() - mock_event_processor.send_changes.assert_not_called() - # Clean up - for env in EXPECTED_ENVIRONMENT_VARIABLES: - del environ[env] - - -@patch(f"{FILE_PATH}.validate_event") -@patch(f"{FILE_PATH}.disconnect_dos_db") -@patch(f"{FILE_PATH}.log_invalid_open_times") -@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") -@patch(f"{FILE_PATH}.add_change_request_to_dynamodb") -@patch(f"{FILE_PATH}.EventProcessor") -@patch(f"{FILE_PATH}.NHSEntity") -@patch(f"{FILE_PATH}.extract_body") -def test_lambda_handler_invalid_open_times( - mock_extract_body, - mock_nhs_entity, - mock_event_processor, - mock_add_change_request_to_dynamodb, - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb, - mock_log_invalid_open_times, - mock_disconnect_dos_db, - mock_validate_event, - change_event, - lambda_context, -): - # Arrange - service = dummy_dos_service() - service.id = 1 - service.uid = 101 - service.odscode = "SLC4501" - service.web = "www.fakesite.com" - service.publicphone = "01462622435" - service.postcode = "S45 1AB" - - change_event["OpeningTimes"] = [ - { - "Weekday": "Monday", - "OpeningTime": "09:00", - "ClosingTime": "13:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Monday", - "OpeningTime": "12:00", - "ClosingTime": "17:30", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - ] - mock_entity = NHSEntity(change_event) - sqs_event = SQS_EVENT.copy() - sqs_event["Records"][0]["body"] = dumps(change_event) - mock_extract_body.return_value = change_event - mock_nhs_entity.return_value = mock_entity - mock_add_change_request_to_dynamodb.return_value = 1 - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 0 - mock_event_processor.return_value.get_matching_services.return_value = [service] - - for env in EXPECTED_ENVIRONMENT_VARIABLES: - environ[env] = "test" - # Act - lambda_handler(sqs_event, lambda_context) - # Assert - mock_log_invalid_open_times.assert_called_once() - mock_disconnect_dos_db.assert_called_once() - mock_event_processor.get_change_requests.assert_not_called() - mock_event_processor.send_changes.assert_not_called() - # Clean up - for env in EXPECTED_ENVIRONMENT_VARIABLES: - del environ[env] - - -@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") -@patch(f"{FILE_PATH}.add_change_request_to_dynamodb") -@patch(f"{FILE_PATH}.EventProcessor") -@patch(f"{FILE_PATH}.NHSEntity") -@patch(f"{FILE_PATH}.extract_body") -def test_lambda_handler_should_throw_exception( - mock_extract_body, - mock_nhs_entity, - mock_event_processor, - mock_add_change_request_to_dynamodb, - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb, - change_event, - lambda_context, - caplog, -): - # Arrange - service = dummy_dos_service() - service.id = 1 - service.uid = 101 - service.odscode = "SLC4501" - service.web = "www.fakesite.com" - service.publicphone = "01462622435" - service.postcode = "S45 1AB" - - del change_event["OrganisationSubType"] - mock_entity = NHSEntity(change_event) - sqs_event = SQS_EVENT.copy() - sqs_event["Records"][0]["body"] = dumps(change_event) - mock_extract_body.return_value = change_event - mock_nhs_entity.return_value = mock_entity - mock_add_change_request_to_dynamodb.return_value = 1 - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 0 - mock_event_processor.return_value.get_matching_services.return_value = [service] - for env in EXPECTED_ENVIRONMENT_VARIABLES: - environ[env] = "test" - # Act - with caplog.at_level(logging.ERROR): - lambda_handler(sqs_event, lambda_context) - assert "Validation Error" in caplog.text - # Clean up - for env in EXPECTED_ENVIRONMENT_VARIABLES: - del environ[env] - - -def test_lambda_handler_should_throw_exception_if_event_records_len_not_eq_one(lambda_context): - # Arrange - sqs_event = SQS_EVENT.copy() - sqs_event["Records"] = [] - for env in EXPECTED_ENVIRONMENT_VARIABLES: - environ[env] = "test" - - with raises(Exception): - lambda_handler(sqs_event, lambda_context) - for env in EXPECTED_ENVIRONMENT_VARIABLES: - del environ[env] - - -@patch.object(Logger, "error") -def test_lambda_handler_given_env_variable_should_exists_in_given_list(mock_logger, change_event, lambda_context): - # Arrange - sqs_event = SQS_EVENT.copy() - sqs_event["Records"][0]["body"] = dumps(change_event) - environ["dummy"] = "test" - # Act - lambda_handler(sqs_event, lambda_context) - mock_logger.assert_called_with("Environmental variable DB_SERVER not present") - del environ["dummy"] - - -@patch.object(Logger, "error") -@patch(f"{FILE_PATH}.add_change_request_to_dynamodb") -@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") -@patch(f"{FILE_PATH}.EventProcessor") -@patch(f"{FILE_PATH}.NHSEntity") -@patch(f"{FILE_PATH}.extract_body") -def test_lambda_handler_no_sequence_number( - mock_extract_body, - mock_nhs_entity, - mock_event_processor, - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb, - mock_add_change_request_to_dynamodb, - mock_logger, - change_event, - lambda_context, -): - # Arrange - mock_entity = NHSEntity(change_event) - sqs_event = SQS_EVENT.copy() - sqs_event["Records"][0]["body"] = dumps(change_event) - del sqs_event["Records"][0]["messageAttributes"]["sequence-number"] - mock_extract_body.return_value = change_event - mock_nhs_entity.return_value = mock_entity - mock_add_change_request_to_dynamodb.return_value = None - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 0 - for env in EXPECTED_ENVIRONMENT_VARIABLES: - environ[env] = "test" - # Act - response = lambda_handler(sqs_event, lambda_context) - # Assert - assert response is None, f"Response should be None but is {response}" - mock_nhs_entity.assert_not_called() - mock_event_processor.assert_not_called() - mock_event_processor.send_changes.assert_not_called() - mock_logger.assert_called_with("No sequence number provided, so message will be ignored.") - # Clean up - for env in EXPECTED_ENVIRONMENT_VARIABLES: - del environ[env] - - -@patch.object(Logger, "error") -@patch(f"{FILE_PATH}.add_change_request_to_dynamodb") -@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") -@patch(f"{FILE_PATH}.get_sequence_number") -@patch(f"{FILE_PATH}.EventProcessor") -@patch(f"{FILE_PATH}.NHSEntity") -@patch(f"{FILE_PATH}.extract_body") -def test_lambda_handler_sequence_number_is_less_than_db_sequence_number( - mock_extract_body, - mock_nhs_entity, - mock_event_processor, - mock_get_sequence_number, - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb, - mock_add_change_request_to_dynamodb, - mock_logger, - change_event, - lambda_context, -): - # Arrange - mock_entity = NHSEntity(change_event) - sqs_event = SQS_EVENT.copy() - sqs_event["Records"][0]["body"] = dumps(change_event) - mock_extract_body.return_value = change_event - mock_nhs_entity.return_value = mock_entity - mock_add_change_request_to_dynamodb.return_value = None - mock_get_sequence_number.return_value = 1 - mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 3 - for env in EXPECTED_ENVIRONMENT_VARIABLES: - environ[env] = "test" - # Act - response = lambda_handler(sqs_event, lambda_context) - # Assert - assert response is None, f"Response should be None but is {response}" - mock_nhs_entity.assert_not_called() - mock_event_processor.assert_not_called() - mock_event_processor.send_changes.assert_not_called() - mock_logger.assert_called_with( - "Sequence id is smaller than the existing one in db for a given odscode, so will be ignored", - extra={"incoming_sequence_number": 1, "db_latest_sequence_number": 3}, - ) - # Clean up - for env in EXPECTED_ENVIRONMENT_VARIABLES: - del environ[env] - - -SQS_EVENT = { - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "Test message.", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1642619743522", - "SenderId": "AIDAIENQZJOLO23YVJ4VO", - "ApproximateFirstReceiveTimestamp": "1545082649185", - }, - "messageAttributes": { - "correlation-id": {"stringValue": "1", "dataType": "String"}, - "sequence-number": {"stringValue": "1", "dataType": "Number"}, - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", - "awsRegion": "us-east-2", - } - ] -} - - -class InvocationTracker(object): - invocations = 0 - - @staticmethod - def record(): - InvocationTracker.invocations += 1 - - @staticmethod - def reset(): - InvocationTracker.invocations = 0 diff --git a/application/event_processor/tests/test_reporting.py b/application/event_processor/tests/test_reporting.py deleted file mode 100644 index 78193e5d4..000000000 --- a/application/event_processor/tests/test_reporting.py +++ /dev/null @@ -1,252 +0,0 @@ -from unittest.mock import patch -import json - -from aws_lambda_powertools import Logger - -from common.dos import VALID_STATUS_ID -from common.constants import ( - HIDDEN_OR_CLOSED_REPORT_ID, - UNMATCHED_PHARMACY_REPORT_ID, - INVALID_POSTCODE_REPORT_ID, - INVALID_OPEN_TIMES_REPORT_ID, - UNMATCHED_SERVICE_TYPE_REPORT_ID, - GENERIC_BANK_HOLIDAY_REPORT_ID, -) -from common.opening_times import OpenPeriod -from common.tests.conftest import dummy_dos_service -from ..nhs import NHSEntity -from ..reporting import ( - log_invalid_open_times, - log_unmatched_service_types, - log_closed_or_hidden_services, - log_unmatched_nhsuk_service, - log_invalid_nhsuk_postcode, - log_service_with_generic_bank_holiday, -) - - -@patch.object(Logger, "warning") -def test_log_closed_or_hidden_services(mock_logger, change_event): - # Arrange - nhs_entity = NHSEntity(change_event) - dos_service = dummy_dos_service() - matching_services = [dos_service] - # Act - log_closed_or_hidden_services(nhs_entity, matching_services) - # Assert - assert ( - HIDDEN_OR_CLOSED_REPORT_ID == "HIDDEN_OR_CLOSED" - ), f"Report ID should be HIDDEN_OR_CLOSED but was {HIDDEN_OR_CLOSED_REPORT_ID}" - mock_logger.assert_called_with( - "NHS Service marked as closed or hidden, no change requests will be produced from this event", - extra={ - "report_key": HIDDEN_OR_CLOSED_REPORT_ID, - "dos_service_id": dos_service.id, - "dos_service_uid": dos_service.uid, - "nhsuk_odscode": nhs_entity.odscode, - "dos_service_publicname": dos_service.name, - "nhsuk_service_status": nhs_entity.org_status, - "nhsuk_service_type": nhs_entity.org_type, - "nhsuk_sector": nhs_entity.org_sub_type, - "dos_service_status": VALID_STATUS_ID, - "dos_service_type": dos_service.typeid, - "dos_service_type_name": dos_service.servicename, - }, - ) - - -@patch.object(Logger, "warning") -def test_log_unmatched_nhsuk_service(mock_logger): - # Arrange - nhs_entity = NHSEntity( - { - "ODSCode": "SLC4X", - "OrganisationName": "OrganisationName", - "OrganisationTypeId": "PHA", - "OrganisationStatus": "OrganisationStatus", - "OrganisationSubType": "OrganisationSubType", - "Address2": "address2", - "Address3": "address3", - "City": "city", - "County": "country", - "Postcode": "MK2 4AX", - } - ) - # Act - log_unmatched_nhsuk_service(nhs_entity) - # Assert - assert ( - UNMATCHED_PHARMACY_REPORT_ID == "UNMATCHED_PHARMACY" - ), f"Log ID should be UNMATCHED_PHARMACY but was {UNMATCHED_PHARMACY_REPORT_ID}" - mock_logger.assert_called_with( - f"No matching DOS services found that fit all criteria for ODSCode '{nhs_entity.odscode}'", - extra={ - "report_key": UNMATCHED_PHARMACY_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "nhsuk_organisation_typeid": nhs_entity.org_type_id, - "nhsuk_organisation_status": nhs_entity.org_status, - "nhsuk_organisation_subtype": nhs_entity.org_sub_type, - "nhsuk_address1": nhs_entity.entity_data.get("Address1", ""), - "nhsuk_address2": nhs_entity.entity_data.get("Address2", ""), - "nhsuk_address3": nhs_entity.entity_data.get("Address3", ""), - "nhsuk_city": nhs_entity.entity_data.get("City", ""), - "nhsuk_county": nhs_entity.entity_data.get("County", ""), - "nhsuk_postcode": nhs_entity.postcode, - "nhsuk_parent_organisation_name": nhs_entity.parent_org_name, - }, - ) - - -@patch.object(Logger, "warning") -def test_log_invalid_nhsuk_postcode(mock_logger): - # Arrange - nhs_entity = NHSEntity( - {"Address1": "address1", "Address2": "address2", "Address3": "address3", "City": "city", "County": "county"} - ) - nhs_entity.odscode = "SLC4X" - nhs_entity.org_name = "OrganisationName" - nhs_entity.org_type_id = "PHA" - nhs_entity.org_status = "OrganisationStatus" - nhs_entity.org_sub_type = "OrganisationSubType" - nhs_entity.postcode = "MK2 XXX" - - dos_service = dummy_dos_service() - # Act - log_invalid_nhsuk_postcode(nhs_entity, dos_service) - # Assert - assert ( - INVALID_POSTCODE_REPORT_ID == "INVALID_POSTCODE" - ), f"Log ID should be INVALID_POSTCODE but was {INVALID_POSTCODE_REPORT_ID}" - mock_logger.assert_called_with( - f"NHS entity '{nhs_entity.odscode}' postcode '{nhs_entity.postcode}' is not a valid DoS postcode!", - extra={ - "report_key": INVALID_POSTCODE_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "nhsuk_organisation_type": nhs_entity.org_type, - "nhsuk_organisation_subtype": nhs_entity.org_sub_type, - "nhsuk_address1": nhs_entity.address_lines[0], - "nhsuk_address2": nhs_entity.address_lines[1], - "nhsuk_address3": nhs_entity.address_lines[2], - "nhsuk_city": nhs_entity.city, - "nhsuk_postcode": nhs_entity.postcode, - "nhsuk_county": nhs_entity.county, - "validation_error_reason": "Postcode not valid/found on DoS", - "dos_service": dos_service.uid, - "dos_service_type_name": dos_service.servicename, - }, - ) - - -@patch.object(Logger, "warning") -def test_log_invalid_open_times(mock_logger): - # Arrange - opening_times = [ - { - "Weekday": "Monday", - "OpeningTime": "09:00", - "ClosingTime": "13:00", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - { - "Weekday": "Monday", - "OpeningTime": "12:00", - "ClosingTime": "17:30", - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - }, - ] - nhs_entity = NHSEntity({"OpeningTimes": opening_times}) - nhs_entity.odscode = "SLC4X" - nhs_entity.org_name = "OrganisationName" - - dos_services = [dummy_dos_service() for i in range(3)] - # Act - log_invalid_open_times(nhs_entity, dos_services) - # Assert - mock_logger.assert_called_with( - f"NHS Entity '{nhs_entity.odscode}' has a misformatted or illogical set of opening times.", - extra={ - "report_key": INVALID_OPEN_TIMES_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "nhsuk_open_times_payload": json.dumps(opening_times), - "dos_services": ", ".join(str(service.uid) for service in dos_services), - "dos_service_type_name": ", ".join(str(service.servicename) for service in dos_services), - }, - ) - - -@patch.object(Logger, "warning") -def test_log_service_with_generic_bank_holiday(mock_logger): - # Arrange - nhs_entity = NHSEntity({}) - nhs_entity.odscode = "SLC4X" - nhs_entity.org_name = "OrganisationName" - dos_service = dummy_dos_service() - open_periods = [OpenPeriod.from_string("08:00-13:00"), OpenPeriod.from_string("04:00-18:00")] - dos_service._standard_opening_times.generic_bankholiday = open_periods - - # Act - log_service_with_generic_bank_holiday(nhs_entity, dos_service) - # Assert - mock_logger.assert_called_with( - f"DoS Service uid={dos_service.uid} has a generic BankHoliday Standard opening time set in DoS", - extra={ - "report_key": GENERIC_BANK_HOLIDAY_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "dos_service_uid": dos_service.uid, - "dos_service_name": dos_service.name, - "dos_service_type_id": dos_service.typeid, - "bank_holiday_opening_times": OpenPeriod.list_string(open_periods), - "nhsuk_parentorg": nhs_entity.parent_org_name, - "dos_service_type_name": dos_service.servicename, - }, - ) - - -@patch.object(Logger, "warning") -def test_log_unmatched_service_types(mock_logger): - # Arrange - nhs_entity = NHSEntity( - {"Address1": "address1", "Address2": "address2", "Address3": "address3", "City": "city", "County": "county"} - ) - nhs_entity.odscode = "SLC4X" - nhs_entity.org_name = "OrganisationName" - nhs_entity.org_type_id = "PHA" - nhs_entity.org_status = "OrganisationStatus" - nhs_entity.org_sub_type = "OrganisationSubType" - nhs_entity.postcode = "MK2 XXX" - - dos_service = dummy_dos_service() - dos_service.typeid = 999 - unmatched_service_types = [dos_service] - # Act - log_unmatched_service_types(nhs_entity, unmatched_service_types) - # Assert - assert ( - UNMATCHED_SERVICE_TYPE_REPORT_ID == "UNMATCHED_SERVICE_TYPE" - ), f"Log ID should be UNMATCHED_SERVICE_TYPE but was {UNMATCHED_SERVICE_TYPE_REPORT_ID}" - mock_logger.assert_called_with( - f"NHS entity '{nhs_entity.odscode}' service type '{ dos_service.typeid}' is not valid!", - extra={ - "report_key": UNMATCHED_SERVICE_TYPE_REPORT_ID, - "nhsuk_odscode": nhs_entity.odscode, - "nhsuk_organisation_name": nhs_entity.org_name, - "nhsuk_organisation_typeid": nhs_entity.org_type_id, - "nhsuk_organisation_status": nhs_entity.org_status, - "nhsuk_organisation_subtype": nhs_entity.org_sub_type, - "nhsuk_parent_organisation_name": nhs_entity.parent_org_name, - "dos_service_uid": dos_service.uid, - "dos_service_id": dos_service.id, - "dos_service_publicname": dos_service.name, - "dos_service_status": VALID_STATUS_ID, - "dos_service_typeid": dos_service.typeid, - "dos_service_type_name": dos_service.servicename, - }, - ) diff --git a/application/event_replay/event_replay.py b/application/event_replay/event_replay.py index e911f506b..62d635358 100644 --- a/application/event_replay/event_replay.py +++ b/application/event_replay/event_replay.py @@ -1,9 +1,10 @@ from decimal import Decimal from os import getenv from time import time_ns -from typing import Any, Dict +from typing import Any -from aws_lambda_powertools import Logger, Tracer +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.tracing import Tracer from aws_lambda_powertools.utilities.typing import LambdaContext from boto3 import client from boto3.dynamodb.types import TypeDeserializer @@ -17,9 +18,9 @@ @tracer.capture_lambda_handler() @unhandled_exception_logging -@logger.inject_lambda_context -def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> str: - """Entrypoint handler for the authoriser lambda +@logger.inject_lambda_context(clear_state=True) +def lambda_handler(event: dict[str, Any], context: LambdaContext) -> str: # noqa: ARG001 + """Entrypoint handler for the authoriser lambda. Args: event (Dict[str, Any]): Lambda function invocation event @@ -33,25 +34,47 @@ def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> str: validate_event(event) odscode = event["odscode"] sequence_number = event["sequence_number"] - logger.append_keys(ods_code=odscode) - logger.append_keys(sequence_number=sequence_number) + logger.append_keys(ods_code=odscode, sequence_number=sequence_number) change_event = get_change_event(odscode, Decimal(sequence_number)) + org_type_id = change_event.get("OrganisationTypeId") + logger.append_keys(org_type_id=org_type_id) send_change_event(change_event, odscode, int(sequence_number), correlation_id) return dumps({"message": "The change event has been re-sent successfully", "correlation_id": correlation_id}) -def validate_event(event: Dict[str, Any]) -> None: +def validate_event(event: dict[str, Any]) -> None: + """Validate the event payload. + + Args: + event (dict[str, Any]): The event payload + """ if "odscode" not in event: - raise Exception("Missing 'odscode' in event") + msg = "Missing 'odscode' in event" + raise ValueError(msg) if "sequence_number" not in event: - raise Exception("Missing 'sequence_number' in event") + msg = "Missing 'sequence_number' in event" + raise ValueError(msg) + + +def build_correlation_id() -> str: + """Build a correlation id for the event replay. + + Returns: + str: The correlation id + """ + return f'{time_ns()}-{getenv("ENVIRONMENT")}-replayed-event' -def build_correlation_id(): - return f'{time_ns()}-{getenv("ENV")}-replayed-event' +def get_change_event(odscode: str, sequence_number: Decimal) -> dict[str, Any]: + """Get the change event from dynamodb. + Args: + odscode (str): The ods code of the organisation + sequence_number (Decimal): The sequence number of the change event -def get_change_event(odscode: str, sequence_number: Decimal) -> Dict[str, Any]: + Returns: + dict[str, Any]: The change event + """ response = client("dynamodb").query( TableName=getenv("CHANGE_EVENTS_TABLE_NAME"), IndexName="gsi_ods_sequence", @@ -69,9 +92,10 @@ def get_change_event(odscode: str, sequence_number: Decimal) -> Dict[str, Any]: ScanIndexForward=False, ) if len(response["Items"]) == 0: - raise Exception(f"No change event found for ods code {odscode} and sequence number {sequence_number}") + msg = f"No change event found for ods code {odscode} and sequence number {sequence_number}" + raise ValueError(msg) item = response["Items"][0] - logger.info("Retrieved change event from dynamodb", extra={"item": item}) + logger.info("Retrieved change event from dynamodb", item=item) deserializer = TypeDeserializer() change_event = {k: deserializer.deserialize(v) for k, v in item.items()}["Event"] logger.append_keys(change_event=change_event) @@ -79,10 +103,18 @@ def get_change_event(odscode: str, sequence_number: Decimal) -> Dict[str, Any]: return change_event -def send_change_event(change_event: Dict[str, Any], odscode: str, sequence_number: int, correlation_id: str): +def send_change_event(change_event: dict[str, Any], odscode: str, sequence_number: int, correlation_id: str) -> None: + """Send the change event to the change event SQS queue. + + Args: + change_event (dict[str, Any]): The change event + odscode (str): The ods code of the organisation + sequence_number (int): The sequence number of the change event + correlation_id (str): The correlation id of the event replay + """ sqs = client("sqs") - queue_url = sqs.get_queue_url(QueueName=getenv("FIFO_SQS_NAME"))["QueueUrl"] - logger.info("Sending change event to SQS", extra={"queue_url": queue_url}) + queue_url = getenv("CHANGE_EVENT_SQS_URL") + logger.info("Sending change event to SQS", queue_url=queue_url) change_event_str = dumps(change_event) response = sqs.send_message( QueueUrl=queue_url, @@ -93,4 +125,4 @@ def send_change_event(change_event: Dict[str, Any], odscode: str, sequence_numbe "sequence-number": {"StringValue": str(sequence_number), "DataType": "Number"}, }, ) - logger.info("Message send to SQS, response from sqs", extra={"response": response}) + logger.info("Message send to SQS, response from SQS", response=response) diff --git a/application/event_replay/requirements.txt b/application/event_replay/requirements.txt index b358cf04a..a56853a73 100644 --- a/application/event_replay/requirements.txt +++ b/application/event_replay/requirements.txt @@ -1,2 +1,2 @@ -aws-lambda-powertools +aws-lambda-powertools[tracer] ~= 2.43.0 simplejson diff --git a/application/event_replay/tests/test_event_replay.py b/application/event_replay/tests/test_event_replay.py index cf0e86ec4..9b045b7d8 100644 --- a/application/event_replay/tests/test_event_replay.py +++ b/application/event_replay/tests/test_event_replay.py @@ -1,12 +1,12 @@ -from dataclasses import dataclass from decimal import Decimal from json import dumps from os import environ -from typing import Any, Dict -from unittest.mock import patch +from unittest.mock import MagicMock, patch +import pytest +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.utilities.typing import LambdaContext from boto3.dynamodb.types import TypeSerializer -from pytest import fixture, raises from application.event_replay.event_replay import ( build_correlation_id, @@ -19,28 +19,13 @@ FILE_PATH = "application.event_replay.event_replay" -@fixture -def event() -> Dict[str, Any]: - return { - "odscode": "FXXX1", - "sequence_number": "1", - } - - -@fixture -def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "cr-fifo-dlq-handler" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:cr-fifo-dlq-handler" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" - - return LambdaContext() +@pytest.fixture() +def event() -> dict[str, str]: + return {"odscode": "FXXX1", "sequence_number": "1"} -@fixture -def change_event(): +@pytest.fixture() +def change_event() -> None: return { "Address1": "Flat 619", "Address2": "62 Fake Street", @@ -51,12 +36,19 @@ def change_event(): } +@patch.object(Logger, "append_keys") @patch(f"{FILE_PATH}.send_change_event") @patch(f"{FILE_PATH}.get_change_event") @patch(f"{FILE_PATH}.build_correlation_id") def test_lambda_handler( - mock_build_correlation_id, mock_get_change_event, mock_send_change_event, change_event, event, lambda_context -): + mock_build_correlation_id: MagicMock, + mock_get_change_event: MagicMock, + mock_send_change_event: MagicMock, + mock_append_keys: MagicMock, + change_event: dict[str, str], + event: dict[str, str], + lambda_context: LambdaContext, +) -> None: # Arrange correlation_id = "CORRELATION_ID" mock_build_correlation_id.return_value = correlation_id @@ -65,53 +57,53 @@ def test_lambda_handler( response = lambda_handler(event, lambda_context) # Assert assert response == dumps( - {"message": "The change event has been re-sent successfully", "correlation_id": correlation_id} + {"message": "The change event has been re-sent successfully", "correlation_id": correlation_id}, ) + mock_append_keys.assert_any_call(ods_code=event["odscode"], sequence_number=event["sequence_number"]) mock_build_correlation_id.assert_called_once_with() mock_get_change_event.assert_called_once_with(event["odscode"], Decimal(event["sequence_number"])) mock_send_change_event.assert_called_once_with( - change_event, event["odscode"], int(event["sequence_number"]), correlation_id + change_event, + event["odscode"], + int(event["sequence_number"]), + correlation_id, ) -def test_validate_event(event): +def test_validate_event(event: dict[str, str]) -> None: # Act & Assert validate_event(event) -def test_validate_event_no_odscode(event): +def test_validate_event_no_odscode(event: dict[str, str]) -> None: # Arrange del event["odscode"] # Act & Assert - with raises(Exception): + with pytest.raises(ValueError, match="odscode"): validate_event(event) -def test_validate_event_no_sequence_number(event): +def test_validate_event_no_sequence_number(event: dict[str, str]) -> None: # Arrange del event["sequence_number"] # Act & Assert - with raises(Exception): + with pytest.raises(ValueError, match="sequence_number"): validate_event(event) @patch(f"{FILE_PATH}.time_ns") -def test_build_correlation_id(mock_time_ns): +def test_build_correlation_id(mock_time_ns: MagicMock) -> None: # Arrange - env = "unit-test" - environ["ENV"] = env time = "123456789" mock_time_ns.return_value = time # Act response = build_correlation_id() # Assert - assert response == f"{time}-{env}-replayed-event" - # Cleanup - del environ["ENV"] + assert response == f"{time}-local-replayed-event" @patch(f"{FILE_PATH}.client") -def test_get_change_event(mock_client, event, change_event): +def test_get_change_event(mock_client: MagicMock, change_event: dict[str, str], event: dict[str, str]) -> None: # Arrange table_name = "my-table" environ["CHANGE_EVENTS_TABLE_NAME"] = table_name @@ -139,14 +131,16 @@ def test_get_change_event(mock_client, event, change_event): @patch(f"{FILE_PATH}.client") -def test_get_change_event_no_change_event_in_dynamodb(mock_client, event, change_event): +def test_get_change_event_no_change_event_in_dynamodb( + mock_client: MagicMock, change_event: dict[str, str], event: dict[str, str] +) -> None: # Arrange table_name = "my-table" environ["CHANGE_EVENTS_TABLE_NAME"] = table_name environ["AWS_REGION"] = "eu-west-1" mock_client.return_value.query.return_value = {"Items": []} # Act - with raises(Exception): + with pytest.raises(ValueError, match="No change event found for ods code FXXX1 and sequence number 1"): get_change_event(event["odscode"], Decimal(event["sequence_number"])) # Assert mock_client.assert_called_with("dynamodb") @@ -165,18 +159,14 @@ def test_get_change_event_no_change_event_in_dynamodb(mock_client, event, change @patch(f"{FILE_PATH}.client") -def test_send_change_event(mock_client, change_event, event): +def test_send_change_event(mock_client: MagicMock, change_event: dict[str, str], event: dict[str, str]) -> None: # Arrange correlation_id = "CORRELATION_ID" - queue_name = "my-queue" - environ["FIFO_SQS_NAME"] = queue_name - queue_url = "https://sqs.eu-west-1.amazonaws.com/123456789/my-queue" - mock_client().get_queue_url.return_value = {"QueueUrl": queue_url} + environ["CHANGE_EVENT_SQS_URL"] = queue_url = "https://sqs.eu-west-1.amazonaws.com/123456789/my-queue" # Act send_change_event(change_event, event["odscode"], int(event["sequence_number"]), correlation_id) # Assert mock_client.assert_called_with("sqs") - mock_client().get_queue_url.assert_called_with(QueueName=queue_name) mock_client().send_message.assert_called_with( QueueUrl=queue_url, MessageBody=dumps(change_event), @@ -187,4 +177,4 @@ def test_send_change_event(mock_client, change_event, event): }, ) # Clean up - del environ["FIFO_SQS_NAME"] + del environ["CHANGE_EVENT_SQS_URL"] diff --git a/application/event_sender/change_request.py b/application/event_sender/change_request.py deleted file mode 100644 index 425987887..000000000 --- a/application/event_sender/change_request.py +++ /dev/null @@ -1,49 +0,0 @@ -from os import getenv -from typing import Any, Dict -from aws_lambda_powertools import Logger -from change_request_logger import ChangeRequestLogger -from requests import post -from requests.auth import HTTPBasicAuth -from requests.models import Response -from common.secretsmanager import get_secret - -logger = Logger(child=True) - - -class ChangeRequest: - """Change request class to send change requests""" - - change_request_logger = ChangeRequestLogger() - headers: Dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"} - - def __init__(self, change_request_body: Dict[str, Any]) -> None: - """Initialise the change request class, get environment variables and log change request body - - Args: - change_request_body (Dict[str, Any]): The change request - """ - self.change_request_url: str = getenv("DOS_API_GATEWAY_URL") - self.timeout: int = int(getenv("DOS_API_GATEWAY_REQUEST_TIMEOUT")) - secrets = get_secret(getenv("DOS_API_GATEWAY_SECRETS")) - self.authorisation = HTTPBasicAuth( - secrets[getenv("DOS_API_GATEWAY_USERNAME_KEY")], - secrets[getenv("DOS_API_GATEWAY_PASSWORD_KEY")], - ) - self.change_request_body: Dict[str, Any] = change_request_body - - def post_change_request(self, is_health_check: bool) -> Response: - self.change_request_logger.log_change_request_post_attempt(self.change_request_body) - """Post a change request to the API gateway""" - try: - response = post( - url=self.change_request_url, - headers=self.headers, - auth=self.authorisation, - json=self.change_request_body, - timeout=self.timeout, - ) - if not is_health_check: - self.change_request_logger.log_change_request_response(response) - return response - except Exception: - self.change_request_logger.log_change_request_exception() diff --git a/application/event_sender/change_request_logger.py b/application/event_sender/change_request_logger.py deleted file mode 100644 index a4d5c816f..000000000 --- a/application/event_sender/change_request_logger.py +++ /dev/null @@ -1,45 +0,0 @@ -from aws_lambda_powertools import Logger -from typing import Any -from json import loads -from requests import Response - -logger = Logger(child=True) - - -class ChangeRequestLogger: - """Change Request Logging class to log the change request for auditing - - Raises: - ValueError: Raises ValueError if json response from api-gateway if json isn't valid - """ - - def log_change_request_post_attempt(self, change_request_body: Any) -> None: - """Log before attempting to POST change request to DoS API Gateway""" - - logger.info("Attempting to send change request to DoS", extra={"change_request_body": change_request_body}) - - def log_change_request_response(self, response: Response) -> None: - """Log the change request response for auditing - - Args: - response (Response): Response object from posting the change request - """ - if response.ok is True: - response_data = loads(response.text) - changes = response_data.get("dosChanges") - if changes is None or len(changes) == 0: - logger.warning("Change request generated no changes within DoS") - extra = { - "state": "Success", - "response_status_code": response.status_code, - "changes": changes, - "response_text": response.text, - } - logger.info("Successfully send change request to DoS", extra=extra) - else: - extra = {"state": "Failure", "response_status_code": response.status_code, "response_text": response.text} - logger.error("Failed to send change request to DoS", extra=extra) - - def log_change_request_exception(self) -> None: - extra = {"state": "Exception", "exception_reason": "Error posting change request"} - logger.exception("Exception error posting change request to DoS", extra=extra) diff --git a/application/event_sender/event_sender.py b/application/event_sender/event_sender.py deleted file mode 100644 index dd42b06af..000000000 --- a/application/event_sender/event_sender.py +++ /dev/null @@ -1,129 +0,0 @@ -from json import dumps -from os import environ -from time import gmtime, strftime, time_ns -from typing import Dict - -from aws_embedded_metrics import metric_scope -from aws_lambda_powertools import Logger, Tracer -from aws_lambda_powertools.utilities.typing import LambdaContext -from boto3 import client -from change_request import ChangeRequest -from common.dynamodb import put_circuit_is_open -from common.middlewares import unhandled_exception_logging -from common.types import ChangeMetadata, ChangeRequestQueueItem - -tracer = Tracer() -logger = Logger() - - -@tracer.capture_lambda_handler() -@unhandled_exception_logging -@logger.inject_lambda_context -@metric_scope -def lambda_handler(event: ChangeRequestQueueItem, context: LambdaContext, metrics) -> Dict: - """Entrypoint handler for the event_sender lambda - - Args: - event (Dict[str, Any]): Lambda function invocation event - context (LambdaContext): Lambda function context object - """ - sqs = client("sqs") - if not event["is_health_check"]: - logger.info("Not a health check") - metadata: ChangeMetadata = event["metadata"] - odscode = metadata["ods_code"] - logger.append_keys(ods_code=odscode) - dynamo_record_id = metadata["dynamo_record_id"] - logger.append_keys(dynamo_record_id=dynamo_record_id) - logger.set_correlation_id(metadata["correlation_id"]) - message_received = metadata["message_received"] - s, ms = divmod(message_received, 1000) - message_received_pretty = "%s.%03d" % (strftime("%Y-%m-%d %H:%M:%S", gmtime(s)), ms) - logger.append_keys(message_received=message_received_pretty) - logger.info( - "Received change request", - extra={"change_request": event["change_request"]}, - ) - change_request = ChangeRequest(event["change_request"]) - if event["is_health_check"]: - logger.info("Health check") - change_request = ChangeRequest({}) - - before = time_ns() // 1000000 - response = change_request.post_change_request(event["is_health_check"]) - after = time_ns() // 1000000 - metrics.set_namespace("UEC-DOS-INT") - metrics.set_property("level", "INFO") - metrics.set_property("function_name", context.function_name) - metrics.set_dimensions({"ENV": environ["ENV"]}) - - if not event["is_health_check"]: - dos_time = after - before - metrics.set_property("message_received", message_received_pretty) - metrics.set_property("ods_code", odscode) - metrics.set_property("correlation_id", logger.get_correlation_id()) - metrics.set_property("dynamo_record_id", dynamo_record_id) - metrics.put_metric("DosApiLatency", dos_time, "Milliseconds") - - if response is not None and response.ok and not event["is_health_check"]: - diff = after - message_received - metrics.set_property("message", f"Recording change request latency of {diff}") - metrics.put_metric("QueueToDoSLatency", diff, "Milliseconds") - # remove from the queue to avoid reprocessing - sqs.delete_message(QueueUrl=environ["CR_QUEUE_URL"], ReceiptHandle=event["recipient_id"]) - - else: - if event["is_health_check"] and response is not None and response.status_code in [400, 200, 201]: - logger.info("Circuit fixed - closing the circuit") - put_circuit_is_open(environ["CIRCUIT"], False) - elif event["is_health_check"]: - message = "Health check failed, assume DoS api is still down" - logger.warning(message) - metrics.put_metric("DoSApiUnavailable", 1, "Count") - return {"body": dumps({"message": message})} - # No need to change the status of the circuit, it will remain open until a success - else: - # TODO: The current DoS Api returns 500 when it should return 400, this isn't ideal - # as it means we will circuit break unnecessarily and this could happen repeatidly until - # the message is DLQ'd - 5 times, if we can fix that then these message could be sent to the dlq - # and deleted to avoid circuit breaking and even replaying when we know it will fail again - if response is None: - message = ( - "Potentially recoverable, breaking circuit to retry shortly due DoS API Gateway being unavailable" - ) - logger.warning(message) - put_circuit_is_open(environ["CIRCUIT"], True) - metrics.put_metric("DoSApiUnavailable", 1, "Count") - return {"body": dumps({"message": message})} - elif response.status_code >= 500 or response.status_code == 429: - if response.text.strip() == "No changes to make": - logger.warning("Change request generated no changes") - sqs.delete_message(QueueUrl=environ["CR_QUEUE_URL"], ReceiptHandle=event["recipient_id"]) - else: - logger.warning( - "Potentially recoverable, breaking circuit to retry shortly due to DoS API Gateway " - "unable to accept change request" - ) - put_circuit_is_open(environ["CIRCUIT"], True) - elif 400 <= response.status_code < 500: - logger.info("Permanent error sending to DLQ, Not retrying") - sqs.send_message( - QueueUrl=environ["CR_DLQ_URL"], - MessageBody=dumps(event["change_request"]), - MessageDeduplicationId=event["metadata"]["message_deduplication_id"], - MessageGroupId=event["metadata"]["message_group_id"], - MessageAttributes={ - "correlation_id": {"DataType": "String", "StringValue": logger.get_correlation_id()}, - "message_received": {"DataType": "Number", "StringValue": str(message_received)}, - "dynamo_record_id": {"DataType": "String", "StringValue": dynamo_record_id}, - "ods_code": {"DataType": "String", "StringValue": odscode}, - "error_msg": {"DataType": "String", "StringValue": response.text}, - "error_msg_http_code": {"DataType": "String", "StringValue": str(response.status_code)}, - }, - ) - sqs.delete_message(QueueUrl=environ["CR_QUEUE_URL"], ReceiptHandle=event["recipient_id"]) - - metrics.set_property("StatusCode", response.status_code) - metrics.set_property("message", f"DoS API failed with status code {response.status_code}") - metrics.put_metric("DoSApiFail", 1, "Count") - return {"statusCode": response.status_code, "body": dumps({"message": response.text})} diff --git a/application/event_sender/requirements.txt b/application/event_sender/requirements.txt deleted file mode 100644 index 232b96edf..000000000 --- a/application/event_sender/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -aws-embedded-metrics -aws-lambda-powertools -requests diff --git a/application/event_sender/tests/__init__.py b/application/event_sender/tests/__init__.py deleted file mode 100644 index a6505fd1a..000000000 --- a/application/event_sender/tests/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sys import modules -from importlib import import_module - -modules["change_request_logger"] = import_module("event_sender.change_request_logger") -modules["change_request"] = import_module("event_sender.change_request") diff --git a/application/event_sender/tests/test_change_request.py b/application/event_sender/tests/test_change_request.py deleted file mode 100644 index 3207cb01a..000000000 --- a/application/event_sender/tests/test_change_request.py +++ /dev/null @@ -1,139 +0,0 @@ -from os import environ -from unittest.mock import MagicMock, patch -from aws_lambda_powertools import Logger -from requests.auth import HTTPBasicAuth -from responses import POST, activate, add - -from event_sender import change_request -from event_sender.change_request import ChangeRequest - -FILE_PATH = "application.event_sender.change_request" - - -class TestChangeRequest: - CHANGE_REQUEST_EVENT = { - "reference": "1", - "system": "Profile Updater (test)", - "message": "Test message 1531816592293|@./", - "service_id": "49016", - "changes": {"ods_code": "f0000", "phone": "0118 999 88199 9119 725 3", "website": "https://www.google.pl"}, - } - CORRELATION_ID = 1 - WEBSITE = "https://test.com" - TIMEOUT = "10" - USERNAME_KEY = "username_sm_key" - PASSWORD_KEY = "password_sm_key" - SECRETS = {USERNAME_KEY: "username", PASSWORD_KEY: "password"} - AWS_SM_API_GATEWAY_SECRET = "api-gateway-secrets" - - @patch.object(change_request, "get_secret", return_value=SECRETS) - def test__init__(self, get_secret_mock): - # Arrange - environ["PROFILE"] = "remote" - environ["DOS_API_GATEWAY_URL"] = self.WEBSITE - environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] = self.TIMEOUT - environ["DOS_API_GATEWAY_USERNAME_KEY"] = self.USERNAME_KEY - environ["DOS_API_GATEWAY_PASSWORD_KEY"] = self.PASSWORD_KEY - environ["DOS_API_GATEWAY_SECRETS"] = self.AWS_SM_API_GATEWAY_SECRET - expected_auth = HTTPBasicAuth(self.SECRETS[self.USERNAME_KEY], self.SECRETS[self.PASSWORD_KEY]) - # Act - change_request = ChangeRequest(self.CHANGE_REQUEST_EVENT) - # Assert - assert change_request.headers == {"Content-Type": "application/json", "Accept": "application/json"} - assert change_request.change_request_url == self.WEBSITE - assert change_request.timeout == int(self.TIMEOUT) - assert change_request.authorisation == expected_auth - assert change_request.change_request_body == self.CHANGE_REQUEST_EVENT - assert change_request.headers == { - "Content-Type": "application/json", - "Accept": "application/json", - } - get_secret_mock.assert_called_with(self.AWS_SM_API_GATEWAY_SECRET) - # Clean up - del environ["DOS_API_GATEWAY_URL"] - del environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] - del environ["DOS_API_GATEWAY_USERNAME_KEY"] - del environ["DOS_API_GATEWAY_PASSWORD_KEY"] - del environ["PROFILE"] - - @patch.object(Logger, "get_correlation_id", return_value="CORRELATION") - @patch.object(change_request, "get_secret", return_value=SECRETS) - def test__init__with_correlation_id(self, get_secret_mock, get_correlation_id_mock): - # Arrange - environ["PROFILE"] = "remote" - environ["DOS_API_GATEWAY_URL"] = self.WEBSITE - environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] = self.TIMEOUT - environ["DOS_API_GATEWAY_USERNAME_KEY"] = self.USERNAME_KEY - environ["DOS_API_GATEWAY_PASSWORD_KEY"] = self.PASSWORD_KEY - expected_auth = HTTPBasicAuth(self.SECRETS[self.USERNAME_KEY], self.SECRETS[self.PASSWORD_KEY]) - # Act - change_request = ChangeRequest(self.CHANGE_REQUEST_EVENT) - # Assert - assert change_request.headers == { - "Content-Type": "application/json", - "Accept": "application/json", - } - assert change_request.change_request_url == self.WEBSITE - assert change_request.timeout == int(self.TIMEOUT) - assert change_request.authorisation == expected_auth - assert change_request.change_request_body == self.CHANGE_REQUEST_EVENT - get_secret_mock.assert_called_with(self.AWS_SM_API_GATEWAY_SECRET) - # Clean up - del environ["DOS_API_GATEWAY_URL"] - del environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] - del environ["DOS_API_GATEWAY_USERNAME_KEY"] - del environ["DOS_API_GATEWAY_PASSWORD_KEY"] - del environ["PROFILE"] - - @patch.object(change_request, "get_secret", return_value=SECRETS) - @activate - def test_post_change_request(self, get_secret_mock): - # Arrange - environ["PROFILE"] = "remote" - environ["DOS_API_GATEWAY_URL"] = self.WEBSITE - environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] = self.TIMEOUT - environ["DOS_API_GATEWAY_USERNAME_KEY"] = self.USERNAME_KEY - environ["DOS_API_GATEWAY_PASSWORD_KEY"] = self.PASSWORD_KEY - change_request = ChangeRequest(self.CHANGE_REQUEST_EVENT) - expected_response_body = {"my-key": "my-val"} - status_code = 201 - add(POST, self.WEBSITE, json=expected_response_body, status=status_code) - change_request.change_request_logger = MagicMock() - # Act - response = change_request.post_change_request(False) - # Assert - get_secret_mock.assert_called_with(self.AWS_SM_API_GATEWAY_SECRET) - assert response.status_code == status_code - change_request.change_request_logger.log_change_request_response.assert_called_once_with(response) - # Clean up - del environ["DOS_API_GATEWAY_URL"] - del environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] - del environ["DOS_API_GATEWAY_USERNAME_KEY"] - del environ["DOS_API_GATEWAY_PASSWORD_KEY"] - del environ["PROFILE"] - - @patch.object(change_request, "post", side_effect=Exception("Test exception")) - @patch.object(change_request, "get_secret", return_value=SECRETS) - def test_post_change_request_exception(self, get_secret_mock, mock_post): - # Arrange - environ["PROFILE"] = "remote" - environ["DOS_API_GATEWAY_URL"] = self.WEBSITE - environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] = self.TIMEOUT - environ["DOS_API_GATEWAY_USERNAME_KEY"] = self.USERNAME_KEY - environ["DOS_API_GATEWAY_PASSWORD_KEY"] = self.PASSWORD_KEY - change_request = ChangeRequest(self.CHANGE_REQUEST_EVENT) - cr_logger_mock = MagicMock() - change_request.change_request_logger = cr_logger_mock - mock_post.side_effect = Exception("Test exception") - # Act - change_request.post_change_request(False) - # Assert - mock_post.assert_called() - cr_logger_mock.log_change_request_exception.assert_called() - cr_logger_mock.log_change_request_response.assert_not_called() - # Clean up - del environ["DOS_API_GATEWAY_URL"] - del environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] - del environ["DOS_API_GATEWAY_USERNAME_KEY"] - del environ["DOS_API_GATEWAY_PASSWORD_KEY"] - del environ["PROFILE"] diff --git a/application/event_sender/tests/test_change_request_logger.py b/application/event_sender/tests/test_change_request_logger.py deleted file mode 100644 index ae192690b..000000000 --- a/application/event_sender/tests/test_change_request_logger.py +++ /dev/null @@ -1,135 +0,0 @@ -from unittest.mock import patch -from aws_lambda_powertools import Logger -from requests.models import Response -from responses import add as response_add, POST as RESPONSE_POST, activate as responses_activate -from requests import post as request_post -from json import dumps -import pytest - -from ..change_request_logger import ChangeRequestLogger - - -class TestChangeRequestLogger: - SUCCESS_STATUS_CODES = [200, 201, 202] - FAILURE_STATUS_CODES = [400, 401, 404, 500] - CORRELATION_ID = 2 - - @patch.object(Logger, "info") - @pytest.mark.parametrize("status_code", SUCCESS_STATUS_CODES) - def test_log_change_request_response_success(self, info_logger_mock, status_code: int): - # Arrange - change_request_logger = ChangeRequestLogger() - test_response = Response() - test_response.status_code = int(status_code) - test_response._content = b'{"dosChanges": [{"changeId":"2D9B8BB8-F168-1010-E343-7E0FE2822107"}]}' - expected_extra = { - "state": "Success", - "response_status_code": status_code, - "changes": [{"changeId": "2D9B8BB8-F168-1010-E343-7E0FE2822107"}], - "response_text": test_response.text, - } - # Act - change_request_logger.log_change_request_response(test_response) - # Assert - info_logger_mock.assert_called_with("Successfully send change request to DoS", extra=expected_extra) - - @patch.object(Logger, "error") - @pytest.mark.parametrize("status_code", FAILURE_STATUS_CODES) - def test_log_change_request_response_failure(self, error_logger_mock, status_code: int): - # Arrange - change_request_logger = ChangeRequestLogger() - test_response = Response() - test_response.status_code = int(status_code) - test_response._content = b'{ "key" : "a" }' - expected_extra = { - "state": "Failure", - "response_status_code": status_code, - "response_text": test_response.text, - } - # Act - change_request_logger.log_change_request_response(test_response) - # Assert - error_logger_mock.assert_called_with("Failed to send change request to DoS", extra=expected_extra) - - @patch.object(Logger, "info") - @responses_activate - def test_log_change_request_response(self, info_logger_mock): - # Arrange - change_request_logger = ChangeRequestLogger() - status_code = 201 - changes = [{"changeId": "2D9B8BB8-F168-1010-E343-7E0FE2822107"}] - response_json = {"dosChanges": changes} - response_text = dumps(response_json) - info_logger_expected = { - "state": "Success", - "response_status_code": status_code, - "changes": changes, - "response_text": response_text, - } - response_add(RESPONSE_POST, "https://dummy_url", json=response_json, status=status_code) - change_request_response = request_post("https://dummy_url", data=response_json) - # Act - change_request_logger.log_change_request_response(change_request_response) - # Assert - info_logger_mock.assert_called_with("Successfully send change request to DoS", extra=info_logger_expected) - - @patch.object(Logger, "info") - @patch.object(Logger, "warning") - @responses_activate - def test_log_change_request_response_warn_no_changes(self, warn_logger_mock, info_logger_mock): - # Arrange - change_request_logger = ChangeRequestLogger() - status_code = 201 - changes = [] - response_json = {"dosChanges": changes} - response_text = dumps(response_json) - info_logger_expected = { - "state": "Success", - "response_status_code": status_code, - "changes": changes, - "response_text": response_text, - } - response_add(RESPONSE_POST, "https://dummy_url", json=response_json, status=status_code) - change_request_response = request_post("https://dummy_url", data=response_json) - # Act - change_request_logger.log_change_request_response(change_request_response) - # Assert - info_logger_mock.assert_called_with("Successfully send change request to DoS", extra=info_logger_expected) - warn_logger_mock.assert_called_with("Change request generated no changes within DoS") - - @patch.object(Logger, "info") - @patch.object(Logger, "warning") - @responses_activate - def test_log_change_request_response_warn_none_changes(self, warn_logger_mock, info_logger_mock): - # Arrange - change_request_logger = ChangeRequestLogger() - status_code = 201 - - response_json = {"something": ""} - response_text = dumps(response_json) - info_logger_expected = { - "state": "Success", - "response_status_code": status_code, - "changes": None, - "response_text": response_text, - } - response_add(RESPONSE_POST, "https://dummy_url", json=response_json, status=status_code) - change_request_response = request_post("https://dummy_url", data=response_json) - # Act - change_request_logger.log_change_request_response(change_request_response) - # Assert - info_logger_mock.assert_called_with("Successfully send change request to DoS", extra=info_logger_expected) - warn_logger_mock.assert_called_with("Change request generated no changes within DoS") - - @patch.object(Logger, "exception") - def test_log_change_request_exception(self, exception_logger_mock): - # Arrange - change_request_logger = ChangeRequestLogger() - expected_extra = { - "state": "Exception", - "exception_reason": "Error posting change request", - } - # Act - change_request_logger.log_change_request_exception() - # Assert - exception_logger_mock.assert_called_with("Exception error posting change request to DoS", extra=expected_extra) diff --git a/application/event_sender/tests/test_event_sender.py b/application/event_sender/tests/test_event_sender.py deleted file mode 100644 index cb1c16909..000000000 --- a/application/event_sender/tests/test_event_sender.py +++ /dev/null @@ -1,293 +0,0 @@ -from dataclasses import dataclass -from json import dumps -from os import environ -from unittest.mock import call, patch - -from aws_embedded_metrics.logger.metrics_logger import MetricsLogger -from pytest import fixture - -from application.common.types import ChangeMetadata, ChangeRequestQueueItem - -from ..event_sender import lambda_handler - -CHANGE_REQUEST = { - "reference": "1", - "system": "Profile Updater (test)", - "message": "Test message 1531816592293|@./", - "service_id": "49016", - "changes": {"ods_code": "f0000", "phone": "0118 999 88199 9119 725 3", "website": "https://www.google.pl"}, -} -METADATA: ChangeMetadata = { - "dynamo_record_id": "EXAMPLE", - "correlation_id": "dummy_correlation_id", - "message_received": 1642619743522, - "ods_code": "FX100", - "message_deduplication_id": "dummy_message_deduplication_id", - "message_group_id": "dummy_message_group_id", -} - -EVENT: ChangeRequestQueueItem = { - "change_request": CHANGE_REQUEST, - "recipient_id": "r-1", - "metadata": METADATA, - "is_health_check": False, -} - -FILE_PATH = "application.event_sender.event_sender" - - -@fixture -def mock_logger(): - InvocationTracker.reset() - - async def flush(self): - print("flush called") - InvocationTracker.record() - - MetricsLogger.flush = flush - - -@fixture -def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "event-sender" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:event-sender" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" - - return LambdaContext() - - -class MockResponse: - def __init__(self, status_code, text=""): - self.status_code = status_code - self.text = text - - @property - def ok(self): - return self.status_code < 400 - - -@patch(f"{FILE_PATH}.ChangeRequest") -@patch(f"{FILE_PATH}.time_ns", return_value=1642619746522500523) -@patch.object(MetricsLogger, "put_metric") -@patch.object(MetricsLogger, "set_dimensions") -@patch(f"{FILE_PATH}.client") -def test_lambda_handler_dos_api_success( - mock_client, mock_set_dimension, mock_put_metric, mock_time, mock_change_request, lambda_context, mock_logger -): - # Arrange - environ["CR_QUEUE_URL"] = "test_q" - environ["CIRCUIT"] = "testcircuit" - message = "success" - mock_instance = mock_change_request.return_value - mock_instance.post_change_request.return_value = MockResponse(status_code=201, text=message) - environ["ENV"] = "test" - environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] = "1" - # Act - response = lambda_handler(EVENT, lambda_context) - # Assert - assert response["statusCode"] == 201 - assert response["body"] == dumps({"message": message}) - mock_client.assert_called_with("sqs") - mock_change_request.assert_called_once_with(CHANGE_REQUEST) - mock_instance.post_change_request.assert_called_once_with(False) - mock_set_dimension.assert_called_once_with({"ENV": "test"}) - - mock_put_metric.assert_has_calls( - [call("DosApiLatency", 0, "Milliseconds"), call("QueueToDoSLatency", 3000, "Milliseconds")] - ) - mock_client.return_value.delete_message.assert_called_with( - QueueUrl="test_q", - ReceiptHandle="r-1", - ) - # Cleanup - del environ["CR_QUEUE_URL"] - del environ["CIRCUIT"] - - -@patch(f"{FILE_PATH}.ChangeRequest") -@patch(f"{FILE_PATH}.time_ns", return_value=1642619746522500523) -@patch(f"{FILE_PATH}.put_circuit_is_open") -@patch.object(MetricsLogger, "put_metric") -@patch.object(MetricsLogger, "set_dimensions") -@patch(f"{FILE_PATH}.client") -def test_lambda_handler_dos_api_fail( - mock_client, - mock_set_dimension, - mock_put_metric, - put_circuit_mock, - mock_time, - mock_change_request, - lambda_context, - mock_logger, -): - # Arrange - status_code = 500 - error_msg = "something went wrong" - mock_instance = mock_change_request.return_value - mock_instance.post_change_request.return_value = MockResponse(status_code=status_code, text=error_msg) - environ["ENV"] = "test" - environ["CIRCUIT"] = "testcircuit" - # Act - response = lambda_handler(EVENT, lambda_context) - # Assert - assert response["statusCode"] == status_code - assert response["body"] == dumps({"message": error_msg}) - mock_client.assert_called_with("sqs") - mock_change_request.assert_called_once_with(CHANGE_REQUEST) - mock_change_request().post_change_request.assert_called_once_with(False) - mock_set_dimension.assert_called_once_with({"ENV": "test"}) - mock_put_metric.assert_has_calls([call("DosApiLatency", 0, "Milliseconds"), call("DoSApiFail", 1, "Count")]) - mock_client.return_value.delete_message.assert_not_called() - put_circuit_mock.assert_called_once_with("testcircuit", True) - # Clean up - del environ["CIRCUIT"] - - -@patch(f"{FILE_PATH}.ChangeRequest") -@patch(f"{FILE_PATH}.time_ns", return_value=1642619746522500523) -@patch(f"{FILE_PATH}.put_circuit_is_open") -@patch.object(MetricsLogger, "put_metric") -@patch.object(MetricsLogger, "set_dimensions") -@patch(f"{FILE_PATH}.client") -def test_lambda_handler_dos_api_fail_no_dos( - mock_client, - mock_set_dimension, - mock_put_metric, - put_circuit_mock, - mock_time, - mock_change_request, - lambda_context, - mock_logger, -): - # Arrange - mock_instance = mock_change_request.return_value - mock_instance.post_change_request.return_value = None - environ["ENV"] = "test" - environ["CIRCUIT"] = "testcircuit" - # Act - response = lambda_handler(EVENT, lambda_context) - # Assert - assert response["body"] == dumps( - {"message": "Potentially recoverable, breaking circuit to retry shortly due DoS API Gateway being unavailable"} - ) - mock_client.assert_called_with("sqs") - mock_change_request.assert_called_once_with(CHANGE_REQUEST) - mock_change_request().post_change_request.assert_called_once_with(False) - mock_set_dimension.assert_called_once_with({"ENV": "test"}) - mock_put_metric.assert_has_calls([call("DoSApiUnavailable", 1, "Count")]) - mock_client.return_value.delete_message.assert_not_called() - put_circuit_mock.assert_called_once_with("testcircuit", True) - # Clean up - del environ["CIRCUIT"] - - -@patch(f"{FILE_PATH}.ChangeRequest") -@patch(f"{FILE_PATH}.time_ns", return_value=1642619746522500523) -@patch(f"{FILE_PATH}.put_circuit_is_open") -@patch.object(MetricsLogger, "put_metric") -@patch(f"{FILE_PATH}.client") -def test_lambda_handler_health_check( - mock_client, mock_put_metric, put_circuit_mock, mock_time, mock_change_request, lambda_context, mock_logger -): - # Arrange - environ["CR_QUEUE_URL"] = "test_q" - environ["CIRCUIT"] = "testcircuit" - environ["ENV"] = "test" - environ["DOS_API_GATEWAY_REQUEST_TIMEOUT"] = "1" - environ["CR_DLQ_URL"] = "test_dlq" - status_code = 400 - error_msg = "Bad request" - mock_instance = mock_change_request.return_value - mock_instance.post_change_request.return_value = MockResponse(status_code=status_code, text=error_msg) - HEALTH_EVENT = EVENT.copy() - HEALTH_EVENT["is_health_check"] = True - # Act - response = lambda_handler(HEALTH_EVENT, lambda_context) - # Assert - assert response["statusCode"] == status_code - assert response["body"] == dumps({"message": error_msg}) - mock_client.assert_called_with("sqs") - mock_instance.post_change_request.assert_called_once_with(True) - mock_put_metric.assert_not_called() - put_circuit_mock.assert_called_once_with("testcircuit", False) - # Clean up - environment_variables = ["CIRCUIT", "CR_DLQ_URL", "CR_QUEUE_URL", "ENV", "DOS_API_GATEWAY_REQUEST_TIMEOUT"] - for variable in environment_variables: - del environ[variable] - - -@patch(f"{FILE_PATH}.ChangeRequest") -@patch(f"{FILE_PATH}.time_ns", return_value=1642619746522500523) -@patch(f"{FILE_PATH}.put_circuit_is_open") -@patch.object(MetricsLogger, "put_metric") -@patch.object(MetricsLogger, "set_dimensions") -@patch(f"{FILE_PATH}.client") -def test_lambda_handler_non_recoverable_error( - mock_client, - mock_set_dimension, - mock_put_metric, - put_circuit_mock, - mock_time, - mock_change_request, - lambda_context, - mock_logger, -): - # Arrange - error_msg = "My error" - status_code = 400 - dlq_queue_name = "dlq-queue" - incoming_queue_url = "https://sqs.us-east-1.amazonaws.com/123456789012/incoming-queue" - mock_instance = mock_change_request.return_value - mock_instance.post_change_request.return_value = MockResponse(status_code=status_code, text=error_msg) - - environ["ENV"] = "test" - environ["CIRCUIT"] = "testcircuit" - environ["CR_DLQ_URL"] = dlq_queue_name - environ["CR_QUEUE_URL"] = incoming_queue_url - # Act - response = lambda_handler(EVENT, lambda_context) - # Assert - assert response["statusCode"] == status_code - assert response["body"] == dumps({"message": error_msg}) - mock_client.assert_called_with("sqs") - mock_change_request.assert_called_once_with(CHANGE_REQUEST) - mock_change_request().post_change_request.assert_called_once_with(False) - mock_set_dimension.assert_called_once_with({"ENV": "test"}) - mock_put_metric.assert_has_calls([call("DosApiLatency", 0, "Milliseconds"), call("DoSApiFail", 1, "Count")]) - mock_client().send_message.assert_called_once_with( - QueueUrl=dlq_queue_name, - MessageBody=dumps(EVENT["change_request"]), - MessageDeduplicationId=METADATA["message_deduplication_id"], - MessageGroupId=METADATA["message_group_id"], - MessageAttributes={ - "correlation_id": {"DataType": "String", "StringValue": METADATA["correlation_id"]}, - "message_received": {"DataType": "Number", "StringValue": str(METADATA["message_received"])}, - "dynamo_record_id": {"DataType": "String", "StringValue": METADATA["dynamo_record_id"]}, - "ods_code": {"DataType": "String", "StringValue": METADATA["ods_code"]}, - "error_msg": {"DataType": "String", "StringValue": error_msg}, - "error_msg_http_code": {"DataType": "String", "StringValue": str(status_code)}, - }, - ) - mock_client().delete_message.assert_called_once_with( - QueueUrl=incoming_queue_url, ReceiptHandle=EVENT["recipient_id"] - ) - put_circuit_mock.assert_not_called() - # Clean up - environment_variables = ["CIRCUIT", "CR_DLQ_URL", "CR_QUEUE_URL", "ENV"] - for variable in environment_variables: - del environ[variable] - - -class InvocationTracker(object): - invocations = 0 - - @staticmethod - def record(): - InvocationTracker.invocations += 1 - - @staticmethod - def reset(): - InvocationTracker.invocations = 0 diff --git a/application/fifo_dlq_handler/fifo_dlq_handler.py b/application/fifo_dlq_handler/fifo_dlq_handler.py deleted file mode 100644 index 0c75a2c83..000000000 --- a/application/fifo_dlq_handler/fifo_dlq_handler.py +++ /dev/null @@ -1,54 +0,0 @@ -from aws_lambda_powertools import Logger, Tracer -from aws_lambda_powertools.utilities.data_classes import SQSEvent, event_source -from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext -from aws_embedded_metrics import metric_scope -from common.dynamodb import add_change_request_to_dynamodb -from common.middlewares import unhandled_exception_logging -from common.utilities import extract_body, get_sequence_number, handle_sqs_msg_attributes, get_sqs_msg_attribute -from common.constants import FIFO_DLQ_HANDLER_REPORT_ID - -TTL = 157680000 # int((365*5)*24*60*60) . 5 years in seconds -tracer = Tracer() -logger = Logger() - - -@unhandled_exception_logging() -@tracer.capture_lambda_handler() -@event_source(data_class=SQSEvent) -@logger.inject_lambda_context() -@metric_scope -def lambda_handler(event: SQSEvent, context: LambdaContext, metrics) -> None: - """Entrypoint handler for the lambda - - Args: - event (SQSEvent): Lambda function invocation event (list of 1 SQS Message) - context (LambdaContext): Lambda function context object - """ - record = next(event.records) - attributes = handle_sqs_msg_attributes(record.message_attributes) - correlation_id = get_sqs_msg_attribute(record.message_attributes, "correlation-id") - logger.set_correlation_id(correlation_id) - logger.append_keys(dynamo_record_id=get_sqs_msg_attribute(record.message_attributes, "dynamo_record_id")) - logger.append_keys(message_received=get_sqs_msg_attribute(record.message_attributes, "message_received")) - logger.append_keys(ods_code=get_sqs_msg_attribute(record.message_attributes, "ods_code")) - message = record.body - body = extract_body(message) - error_msg = attributes["error_msg"] - logger.warning( - "FIFO Dead Letter Queue Handler received event", - extra={ - "report_key": FIFO_DLQ_HANDLER_REPORT_ID, - "error_msg": f"Message Abandoned: {error_msg}", - "error_msg_http_code": attributes["error_msg_http_code"], - "payload": body, - }, - ) - metrics.set_namespace("AWS/SQS") - metrics.set_property("level", "WARNING") - metrics.set_property("message", error_msg) - metrics.set_property("correlation_id", logger.get_correlation_id()) - metrics.put_metric("NumberOfMessagesReceived", 1, "Count") - - sqs_timestamp = int(record.attributes["SentTimestamp"]) - sequence_number = get_sequence_number(record) - add_change_request_to_dynamodb(body, sequence_number, sqs_timestamp) diff --git a/application/fifo_dlq_handler/requirements.txt b/application/fifo_dlq_handler/requirements.txt deleted file mode 100644 index e6a88cfcb..000000000 --- a/application/fifo_dlq_handler/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -aws-embedded-metrics -aws-lambda-powertools diff --git a/application/fifo_dlq_handler/tests/test_fifo_dlq_handler.py b/application/fifo_dlq_handler/tests/test_fifo_dlq_handler.py deleted file mode 100644 index 4a5f12bbe..000000000 --- a/application/fifo_dlq_handler/tests/test_fifo_dlq_handler.py +++ /dev/null @@ -1,74 +0,0 @@ -from dataclasses import dataclass -from unittest.mock import patch - -from pytest import fixture -from aws_embedded_metrics.logger.metrics_logger import MetricsLogger -from ..fifo_dlq_handler import lambda_handler - -FILE_PATH = "application.fifo_dlq_handler.fifo_dlq_handler" - - -@fixture -def dead_letter_change_event(): - yield { - "Records": [ - { - "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", - "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", - "body": "Test message.", - "attributes": { - "ApproximateReceiveCount": "1", - "SentTimestamp": "1545082649183", - "SenderId": "AIDAIENQZJOLO23YVJ4VO", - "ApproximateFirstReceiveTimestamp": "1545082649185", - }, - "messageAttributes": { - "correlation-id": { - "stringValue": "059f36b4-87a3-44ab-83d2-661975830a7d", - "stringListValues": [], - "binaryListValues": [], - "dataType": "String", - } - }, - "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", - "eventSource": "aws:sqs", - "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", - "awsRegion": "us-east-2", - } - ] - } - - -@fixture -def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "event-processor" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:event-processor" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" - - return LambdaContext() - - -@patch(f"{FILE_PATH}.extract_body") -@patch(f"{FILE_PATH}.add_change_request_to_dynamodb") -@patch.object(MetricsLogger, "put_metric") -@patch.object(MetricsLogger, "set_dimensions") -def test_lambda_handler( - mock_put_metric, - mock_set_dimensions, - mock_add_change_request_to_dynamodb, - mock_extract_body, - dead_letter_change_event, - lambda_context, -): - # Arrange - extracted_body = "Test message1." - mock_extract_body.return_value = extracted_body - # Act - lambda_handler(dead_letter_change_event, lambda_context) - # Assert - mock_extract_body.assert_called_once_with(dead_letter_change_event["Records"][0]["body"]) - expected_timestamp = int(dead_letter_change_event["Records"][0]["attributes"]["SentTimestamp"]) - mock_add_change_request_to_dynamodb.assert_called_once_with(extracted_body, None, expected_timestamp) diff --git a/application/dos_api_gateway/tests/__init__.py b/application/ingest_change_event/__init__.py similarity index 100% rename from application/dos_api_gateway/tests/__init__.py rename to application/ingest_change_event/__init__.py diff --git a/application/ingest_change_event/change_event_validation.py b/application/ingest_change_event/change_event_validation.py new file mode 100644 index 000000000..6e5964e61 --- /dev/null +++ b/application/ingest_change_event/change_event_validation.py @@ -0,0 +1,101 @@ +from typing import Any + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.utilities.validation import validate +from aws_lambda_powertools.utilities.validation.exceptions import SchemaValidationError + +from common.constants import ( + PHARMACY_ODSCODE_LENGTH, + PHARMACY_ORG_TYPE_ID, + PHARMACY_ORGANISATION_SUB_TYPES, +) +from common.errors import ValidationError + +logger = Logger(child=True) + + +def validate_change_event(event: dict[str, Any]) -> None: + """Validate event using business rules. + + Args: + event (Dict[str, Any]): Lambda function invocation event. + """ + logger.info(f"Attempting to validate event payload: {event}") + try: + validate(event=event, schema=INPUT_SCHEMA) + except SchemaValidationError as exception: + raise ValidationError(exception) from exception + validate_organisation_keys(event.get("OrganisationTypeId"), event.get("OrganisationSubType")) + check_ods_code_length(event["ODSCode"]) + logger.info("Event has been validated") + + +def check_ods_code_length( + odscode: str, +) -> None: + """Check ODS code length as expected, exception raise if error. + + Note: ods code type is checked by schema validation + + Args: + odscode (str): odscode of NHS UK service. + odscode_length (int): expected length of odscode. + """ + logger.debug(f"Checking ODSCode {odscode} length") + if len(odscode) != PHARMACY_ODSCODE_LENGTH: + msg = f"ODSCode Wrong Length, '{odscode}' is not length {PHARMACY_ODSCODE_LENGTH}." + raise ValidationError(msg) + + +def validate_organisation_keys(org_type_id: str, org_sub_type: str) -> None: + """Validate the organisation type id and organisation sub type. + + Args: + org_type_id (str): organisation type id + org_sub_type (str): organisation sub type + + Raises: + ValidationError: Either Org Type ID or Org Sub Type is not part of the valid list + """ + validate_organisation_type_id(org_type_id) + if org_sub_type in PHARMACY_ORGANISATION_SUB_TYPES: + logger.info(f"Subtype type id: {org_sub_type} validated") + else: + msg = f"Unexpected Org Sub Type ID: '{org_sub_type}'" + raise ValidationError(msg) + + +def validate_organisation_type_id(org_type_id: str) -> None: + """Check if the organisation type id is valid. + + Args: + org_type_id (str): organisation type id + """ + if org_type_id == PHARMACY_ORG_TYPE_ID: + logger.debug(f"Org type id: {org_type_id} validated as a pharmacy") + else: + logger.error(f"Org type id: {org_type_id} is not a pharmacy", org_type_id=org_type_id) + msg = f"Unexpected Org Type ID: '{org_type_id}'" + raise ValidationError(msg) + + +INPUT_SCHEMA = { + "$schema": "https://json-schema.org/draft-07/schema", + "type": "object", + "required": ["ODSCode", "OrganisationTypeId", "OrganisationSubType"], + "properties": { + "ODSCode": { + "$id": "#/properties/ODSCode", + "type": "string", + }, + "OrganisationTypeId": { + "$id": "#/properties/OrganisationTypeId", + "type": "string", + }, + "OrganisationSubType": { + "$id": "#/properties/OrganisationSubType", + "type": "string", + }, + }, + "additionalProperties": "true", +} diff --git a/application/ingest_change_event/ingest_change_event.py b/application/ingest_change_event/ingest_change_event.py new file mode 100644 index 000000000..029b6edb8 --- /dev/null +++ b/application/ingest_change_event/ingest_change_event.py @@ -0,0 +1,89 @@ +from json import dumps +from os import getenv +from time import gmtime, strftime + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.tracing import Tracer +from aws_lambda_powertools.utilities.data_classes import SQSEvent, event_source +from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext +from boto3 import client + +from .change_event_validation import validate_change_event +from common.dynamodb import add_change_event_to_dynamodb, get_latest_sequence_id_for_a_given_odscode_from_dynamodb +from common.middlewares import redact_staff_key_from_event, unhandled_exception_logging +from common.types import HoldingQueueChangeEventItem +from common.utilities import extract_body, get_sequence_number + +logger = Logger() +tracer = Tracer() +sqs = client("sqs") + + +@redact_staff_key_from_event() +@unhandled_exception_logging() +@tracer.capture_lambda_handler() +@event_source(data_class=SQSEvent) +@logger.inject_lambda_context( + clear_state=True, + correlation_id_path='Records[0].messageAttributes."correlation-id".stringValue', +) +def lambda_handler(event: SQSEvent, context: LambdaContext) -> None: # noqa: ARG001 + """Entrypoint handler for the ingest change event lambda. + + This lambda runs the change event validation, puts the change event on the dynamodb table + and then sends the validated change event to the delay queue. + + Args: + event (SQSEvent): Lambda function invocation event + context (LambdaContext): Lambda function context object + + Event: The event payload should contain an Update Request + """ + if len(list(event.records)) != 1: + msg = f"{len(list(event.records))} records found in event. Expected 1." + raise ValueError(msg) + + record = next(event.records) + change_event = extract_body(record.body) + validate_change_event(change_event) + ods_code = change_event.get("ODSCode") + logger.append_keys(ods_code=ods_code) + sequence_number = get_sequence_number(record) + sqs_timestamp = int(record.attributes["SentTimestamp"]) + s, ms = divmod(sqs_timestamp, 1000) + logger.warning( + "Change Event received", + sequence_number=sequence_number, + message_received="%s.%03d" % (strftime("%Y-%m-%d %H:%M:%S", gmtime(s)), ms), + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="ChangeEventReceived", + ) + logger.debug("Getting latest sequence number") + db_latest_sequence_number = get_latest_sequence_id_for_a_given_odscode_from_dynamodb(ods_code) + logger.info("Writing change event to dynamo") + record_id = add_change_event_to_dynamodb(change_event, sequence_number, sqs_timestamp) + logger.append_keys(dynamo_record_id=record_id) + + if sequence_number is None: + logger.error("No sequence number provided, so message will be ignored.") + return + elif sequence_number < db_latest_sequence_number: # noqa: RET505 + logger.error( + "Sequence id is smaller than the existing one in db for a given odscode, so will be ignored", + incoming_sequence_number=sequence_number, + db_latest_sequence_number=db_latest_sequence_number, + ) + return + holding_queue_change_event_item = HoldingQueueChangeEventItem( + change_event=change_event, + sequence_number=sequence_number, + message_received=sqs_timestamp, + dynamo_record_id=record_id, + correlation_id=logger.get_correlation_id(), + ) + logger.debug("Change event validated", holding_queue_change_event_item=holding_queue_change_event_item) + sqs.send_message( + QueueUrl=getenv("HOLDING_QUEUE_URL"), + MessageBody=dumps(holding_queue_change_event_item), + MessageGroupId=ods_code, + ) diff --git a/application/ingest_change_event/requirements.txt b/application/ingest_change_event/requirements.txt new file mode 100644 index 000000000..5cf839387 --- /dev/null +++ b/application/ingest_change_event/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools[tracer, validation] ~= 2.43.0 diff --git a/application/ingest_change_event/tests/__init__.py b/application/ingest_change_event/tests/__init__.py new file mode 100644 index 000000000..214eefc86 --- /dev/null +++ b/application/ingest_change_event/tests/__init__.py @@ -0,0 +1,8 @@ +from importlib import import_module +from sys import modules + +modules["opening_times"] = import_module("common.opening_times") +modules["nhs"] = import_module("common.nhs") +modules["dos"] = import_module("common.dos") +modules["errors"] = import_module("common.errors") +modules["change_event_validation"] = import_module("ingest_change_event.change_event_validation") diff --git a/application/ingest_change_event/tests/test_change_event_validation.py b/application/ingest_change_event/tests/test_change_event_validation.py new file mode 100644 index 000000000..d479f718a --- /dev/null +++ b/application/ingest_change_event/tests/test_change_event_validation.py @@ -0,0 +1,123 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from application.ingest_change_event.change_event_validation import ( + ValidationError, + check_ods_code_length, + validate_change_event, + validate_organisation_keys, + validate_organisation_type_id, +) +from common.constants import PHARMACY_ORG_TYPE_ID + +FILE_PATH = "application.ingest_change_event.change_event_validation" + + +@patch(f"{FILE_PATH}.validate_organisation_keys") +def test_validate_change_event(mock_validate_organisation_keys: MagicMock, change_event: dict[str, str]) -> None: + # Act & Assert + validate_change_event(change_event) + + +@patch(f"{FILE_PATH}.validate_organisation_keys") +@patch(f"{FILE_PATH}.check_ods_code_length") +def test_validate_change_event_missing_key( + mock_check_ods_code_length: MagicMock, mock_validate_organisation_keys: MagicMock, change_event: dict[str, str] +) -> None: + # Arrange + del change_event["ODSCode"] + # Act + with pytest.raises(ValidationError): + validate_change_event(change_event) + # Assert + mock_check_ods_code_length.assert_not_called() + mock_validate_organisation_keys.assert_not_called() + + +@pytest.mark.parametrize( + ("odscode"), + [ + ("FXXX1"), + ("AAAAA"), + ("00000"), + ], +) +def test_check_ods_code_length(odscode: str) -> None: + # Act & Assert + check_ods_code_length(odscode) + + +@pytest.mark.parametrize( + ("odscode"), + [ + ("FXXX11"), + ("AAAA"), + ("V0345A"), + ("V01234567"), + ], +) +def test_check_ods_code_length_incorrect_length(odscode: str) -> None: + # Act & Assert + with pytest.raises(ValidationError): + check_ods_code_length(odscode) + + +@pytest.mark.parametrize( + ("org_type_id", "org_sub_type"), + [ + ( + "PHA", + "Community", + ), + ], +) +@patch(f"{FILE_PATH}.validate_organisation_type_id") +def test_validate_organisation_keys( + mock_validate_organisation_type_id: MagicMock, + org_type_id: str, + org_sub_type: str, +) -> None: + # Act & Assert + validate_organisation_keys(org_type_id, org_sub_type) + + +@pytest.mark.parametrize( + ("org_type_id", "org_sub_type"), + [ + ( + "GP", + "RANDOM", + ), + ( + "PHA", + "TEST1", + ), + ], +) +@patch(f"{FILE_PATH}.validate_organisation_type_id") +def test_validate_organisation_keys_org_sub_type_id_exception( + mock_validate_organisation_type_id: MagicMock, + org_type_id: str, + org_sub_type: str, +) -> None: + # Act & Assert + with pytest.raises(ValidationError) as exception: + validate_organisation_keys(org_type_id, org_sub_type) + assert f"Unexpected Org Sub Type ID: '{org_sub_type}'" in str(exception.value) + + +@pytest.mark.parametrize("org_type_id", [PHARMACY_ORG_TYPE_ID]) +def test_validate_organisation_type_id(org_type_id: str) -> None: + # Act + response = validate_organisation_type_id(org_type_id) + # Assert + assert response is None + + +@pytest.mark.parametrize("org_type_id", ["GP", "DEN", "TEST1"]) +def test_validate_organisation_type_id_wrong_org_type_id_exception(org_type_id: str) -> None: + # Act & Assert + with pytest.raises(ValidationError) as exception: + validate_organisation_type_id(org_type_id) + assert f"Unexpected Org Type ID: '{org_type_id}'" in str(exception.value) diff --git a/application/ingest_change_event/tests/test_ingest_change_event.py b/application/ingest_change_event/tests/test_ingest_change_event.py new file mode 100644 index 000000000..98400438a --- /dev/null +++ b/application/ingest_change_event/tests/test_ingest_change_event.py @@ -0,0 +1,315 @@ +from json import dumps +from os import environ +from unittest.mock import MagicMock, patch + +import pytest +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.utilities.typing import LambdaContext + +from application.common.types import HoldingQueueChangeEventItem +from application.ingest_change_event.ingest_change_event import lambda_handler + +FILE_PATH = "application.ingest_change_event.ingest_change_event" + + +@patch(f"{FILE_PATH}.sqs") +@patch(f"{FILE_PATH}.HoldingQueueChangeEventItem") +@patch(f"{FILE_PATH}.add_change_event_to_dynamodb") +@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") +@patch(f"{FILE_PATH}.get_sequence_number") +@patch(f"{FILE_PATH}.validate_change_event") +@patch(f"{FILE_PATH}.extract_body") +def test_lambda_handler( + mock_extract_body: MagicMock, + mock_validate_change_event: MagicMock, + mock_get_sequence_number: MagicMock, + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb: MagicMock, + mock_add_change_event_to_dynamodb: MagicMock, + mock_holding_queue_change_event_item: MagicMock, + mock_sqs: MagicMock, + change_event: dict, + lambda_context: LambdaContext, +) -> None: + # Arrange + event = SQS_EVENT.copy() + event["Records"][0]["body"] = dumps(change_event) + mock_extract_body.return_value = change_event + environ["ENV"] = "test" + environ["HOLDING_QUEUE_URL"] = queue_url = "https://sqs.eu-west-1.amazonaws.com/000000000000/holding-queue" + sqs_timestamp = 1642619743522 + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 1 + mock_get_sequence_number.return_value = sequence_number = 2 + mock_add_change_event_to_dynamodb.return_value = dynamodb_record = "1234567890" + mock_holding_queue_change_event_item.return_value = holding_queue_change_event_item = HoldingQueueChangeEventItem( + change_event=None, + dynamo_record_id=None, + correlation_id=None, + sequence_number=None, + message_received=None, + ) + # Act + response = lambda_handler(event, lambda_context) + # Assert + assert response is None + mock_extract_body.assert_called_once_with(dumps(change_event)) + mock_validate_change_event.assert_called_once_with(change_event) + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.assert_called_once_with(change_event["ODSCode"]) + mock_add_change_event_to_dynamodb.assert_called_once_with(change_event, sequence_number, sqs_timestamp) + mock_holding_queue_change_event_item.assert_called_once_with( + change_event=change_event, + sequence_number=sequence_number, + message_received=sqs_timestamp, + dynamo_record_id=dynamodb_record, + correlation_id="1", + ) + mock_sqs.send_message.assert_called_once_with( + QueueUrl=queue_url, + MessageBody=dumps(holding_queue_change_event_item), + MessageGroupId=change_event["ODSCode"], + ) + # Cleanup + del environ["ENV"] + del environ["HOLDING_QUEUE_URL"] + + +@patch(f"{FILE_PATH}.sqs") +@patch(f"{FILE_PATH}.HoldingQueueChangeEventItem") +@patch(f"{FILE_PATH}.add_change_event_to_dynamodb") +@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") +@patch(f"{FILE_PATH}.get_sequence_number") +@patch(f"{FILE_PATH}.validate_change_event") +def test_lambda_handler_with_sensitive_staff_key( + mock_validate_change_event: MagicMock, + mock_get_sequence_number: MagicMock, + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb: MagicMock, + mock_add_change_event_to_dynamodb: MagicMock, + mock_holding_queue_change_event_item: MagicMock, + mock_sqs: MagicMock, + change_event_staff: dict, + change_event: dict, + lambda_context: LambdaContext, +) -> None: + # Arrange + event = SQS_EVENT.copy() + event["Records"][0]["body"] = dumps(change_event_staff.copy()) + environ["ENV"] = "test" + environ["HOLDING_QUEUE_URL"] = queue_url = "https://sqs.eu-west-1.amazonaws.com/000000000000/holding-queue" + sqs_timestamp = 1642619743522 + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 1 + mock_get_sequence_number.return_value = sequence_number = 2 + mock_add_change_event_to_dynamodb.return_value = dynamodb_record = "1234567890" + mock_holding_queue_change_event_item.return_value = holding_queue_change_event_item = HoldingQueueChangeEventItem( + change_event=None, + dynamo_record_id=None, + correlation_id=None, + sequence_number=None, + message_received=None, + ) + # Act + response = lambda_handler(event, lambda_context) + # Assert + assert response is None + mock_validate_change_event.assert_called_once_with(change_event) + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.assert_called_once_with(change_event["ODSCode"]) + mock_add_change_event_to_dynamodb.assert_called_once_with(change_event, sequence_number, sqs_timestamp) + mock_holding_queue_change_event_item.assert_called_once_with( + change_event=change_event, + sequence_number=sequence_number, + message_received=sqs_timestamp, + dynamo_record_id=dynamodb_record, + correlation_id="1", + ) + mock_sqs.send_message.assert_called_once_with( + QueueUrl=queue_url, + MessageBody=dumps(holding_queue_change_event_item), + MessageGroupId=change_event["ODSCode"], + ) + # Cleanup + del environ["ENV"] + del environ["HOLDING_QUEUE_URL"] + + +@patch.object(Logger, "error") +@patch(f"{FILE_PATH}.sqs") +@patch(f"{FILE_PATH}.HoldingQueueChangeEventItem") +@patch(f"{FILE_PATH}.add_change_event_to_dynamodb") +@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") +@patch(f"{FILE_PATH}.get_sequence_number") +@patch(f"{FILE_PATH}.validate_change_event") +@patch(f"{FILE_PATH}.extract_body") +def test_lambda_handler_no_sequence_number( + mock_extract_body: MagicMock, + mock_validate_change_event: MagicMock, + mock_get_sequence_number: MagicMock, + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb: MagicMock, + mock_add_change_event_to_dynamodb: MagicMock, + mock_holding_queue_change_event_item: MagicMock, + mock_sqs: MagicMock, + mock_logger_error: MagicMock, + change_event: dict, + lambda_context: LambdaContext, +) -> None: + # Arrange + event = SQS_EVENT.copy() + event["Records"][0]["body"] = dumps(change_event) + mock_extract_body.return_value = change_event + environ["ENV"] = "test" + environ["HOLDING_QUEUE_URL"] = "https://sqs.eu-west-1.amazonaws.com/000000000000/holding-queue" + sqs_timestamp = 1642619743522 + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 1 + mock_get_sequence_number.return_value = sequence_number = None + mock_add_change_event_to_dynamodb.return_value = "1234567890" + mock_holding_queue_change_event_item.return_value = HoldingQueueChangeEventItem( + change_event=None, + dynamo_record_id=None, + correlation_id=None, + sequence_number=None, + message_received=None, + ) + # Act + response = lambda_handler(event, lambda_context) + # Assert + assert response is None + mock_extract_body.assert_called_once_with(dumps(change_event)) + mock_validate_change_event.assert_called_once_with(change_event) + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.assert_called_once_with(change_event["ODSCode"]) + mock_add_change_event_to_dynamodb.assert_called_once_with(change_event, sequence_number, sqs_timestamp) + mock_holding_queue_change_event_item.assert_not_called() + mock_sqs.send_message.assert_not_called() + mock_logger_error.assert_called_once_with("No sequence number provided, so message will be ignored.") + # Cleanup + del environ["ENV"] + del environ["HOLDING_QUEUE_URL"] + + +@patch.object(Logger, "error") +@patch(f"{FILE_PATH}.sqs") +@patch(f"{FILE_PATH}.HoldingQueueChangeEventItem") +@patch(f"{FILE_PATH}.add_change_event_to_dynamodb") +@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") +@patch(f"{FILE_PATH}.get_sequence_number") +@patch(f"{FILE_PATH}.validate_change_event") +@patch(f"{FILE_PATH}.extract_body") +def test_lambda_handler_less_than_latest_sequence_number( + mock_extract_body: MagicMock, + mock_validate_change_event: MagicMock, + mock_get_sequence_number: MagicMock, + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb: MagicMock, + mock_add_change_event_to_dynamodb: MagicMock, + mock_holding_queue_change_event_item: MagicMock, + mock_sqs: MagicMock, + mock_logger_error: MagicMock, + change_event: dict, + lambda_context: LambdaContext, +) -> None: + # Arrange + event = SQS_EVENT.copy() + event["Records"][0]["body"] = dumps(change_event) + mock_extract_body.return_value = change_event + environ["ENV"] = "test" + environ["HOLDING_QUEUE_URL"] = "https://sqs.eu-west-1.amazonaws.com/000000000000/holding-queue" + sqs_timestamp = 1642619743522 + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = db_latest_sequence_number = 2 + mock_get_sequence_number.return_value = sequence_number = 1 + mock_add_change_event_to_dynamodb.return_value = "1234567890" + mock_holding_queue_change_event_item.return_value = HoldingQueueChangeEventItem( + change_event=None, + dynamo_record_id=None, + correlation_id=None, + sequence_number=None, + message_received=None, + ) + # Act + response = lambda_handler(event, lambda_context) + # Assert + assert response is None + mock_extract_body.assert_called_once_with(dumps(change_event)) + mock_validate_change_event.assert_called_once_with(change_event) + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.assert_called_once_with(change_event["ODSCode"]) + mock_add_change_event_to_dynamodb.assert_called_once_with(change_event, sequence_number, sqs_timestamp) + mock_holding_queue_change_event_item.assert_not_called() + mock_sqs.send_message.assert_not_called() + mock_logger_error.assert_called_once_with( + "Sequence id is smaller than the existing one in db for a given odscode, so will be ignored", + incoming_sequence_number=sequence_number, + db_latest_sequence_number=db_latest_sequence_number, + ) + # Cleanup + del environ["ENV"] + del environ["HOLDING_QUEUE_URL"] + + +@patch(f"{FILE_PATH}.sqs") +@patch(f"{FILE_PATH}.HoldingQueueChangeEventItem") +@patch(f"{FILE_PATH}.add_change_event_to_dynamodb") +@patch(f"{FILE_PATH}.get_latest_sequence_id_for_a_given_odscode_from_dynamodb") +@patch(f"{FILE_PATH}.get_sequence_number") +@patch(f"{FILE_PATH}.validate_change_event") +@patch(f"{FILE_PATH}.extract_body") +def test_lambda_handler_mutiple_records( + mock_extract_body: MagicMock, + mock_validate_change_event: MagicMock, + mock_get_sequence_number: MagicMock, + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb: MagicMock, + mock_add_change_event_to_dynamodb: MagicMock, + mock_holding_queue_change_event_item: MagicMock, + mock_sqs: MagicMock, + change_event: dict, + lambda_context: LambdaContext, +) -> None: + # Arrange + event = SQS_EVENT.copy() + record = event["Records"][0] + records_list = [record, record, record] + event["Records"] = records_list + mock_extract_body.return_value = change_event + environ["ENV"] = "test" + environ["HOLDING_QUEUE_URL"] = "https://sqs.eu-west-1.amazonaws.com/000000000000/holding-queue" + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.return_value = 2 + mock_get_sequence_number.return_value = 1 + mock_add_change_event_to_dynamodb.return_value = "1234567890" + mock_holding_queue_change_event_item.return_value = HoldingQueueChangeEventItem( + change_event=None, + dynamo_record_id=None, + correlation_id=None, + sequence_number=None, + message_received=None, + ) + # Act + with pytest.raises(ValueError, match="3 records found in event. Expected 1."): + lambda_handler(event, lambda_context) + # Assert + mock_extract_body.assert_not_called() + mock_validate_change_event.assert_not_called() + mock_get_latest_sequence_id_for_a_given_odscode_from_dynamodb.assert_not_called() + mock_add_change_event_to_dynamodb.assert_not_called() + mock_holding_queue_change_event_item.assert_not_called() + mock_sqs.send_message.assert_not_called() + # Cleanup + del environ["ENV"] + del environ["HOLDING_QUEUE_URL"] + + +SQS_EVENT = { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": "Test message.", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1642619743522", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185", + }, + "messageAttributes": { + "correlation-id": {"stringValue": "1", "dataType": "String"}, + "sequence-number": {"stringValue": "1", "dataType": "Number"}, + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2", + }, + ], +} diff --git a/application/orchestrator/orchestrator.py b/application/orchestrator/orchestrator.py deleted file mode 100644 index aef59fd1e..000000000 --- a/application/orchestrator/orchestrator.py +++ /dev/null @@ -1,111 +0,0 @@ -from json import dumps -from os import getenv, environ -from typing import Any, Dict - -from aws_lambda_powertools import Logger, Tracer -from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext -from boto3 import client -from common.dynamodb import get_circuit_is_open -from common.middlewares import unhandled_exception_logging -from common.utilities import extract_body -from common.types import ChangeMetadata, ChangeRequestQueueItem -from time import strftime, gmtime, time, sleep - -logger = Logger() -tracer = Tracer() - -QUEUE_URL = getenv("CR_QUEUE_URL") - - -@unhandled_exception_logging() -@tracer.capture_lambda_handler() -@logger.inject_lambda_context -def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> None: - """Entrypoint handler for the orchestrator lambda - - Args: - event (Dict[str, Any]): Lambda function invocation event - context (LambdaContext): Lambda function context object - - Event: The event payload should contain a Change Request - """ - sqs = client("sqs") - lambda_client = client("lambda") - start = time() - loop = 0 - TIME_TO_SLEEP = 1 / int(getenv("DOS_TRANSACTIONS_PER_SECOND", default=3)) - while time() < start + int(environ["RUN_FOR"]): - circuit_open = get_circuit_is_open(environ["CIRCUIT"]) - if circuit_open: - # Wait then continue - sleep(int(environ["SLEEP_FOR_WHEN_OPEN"])) - change_request_queue_item: ChangeRequestQueueItem = { - "is_health_check": True, - "change_request": {}, - "recipient_id": None, - "metadata": None, - } - logger.info( - "Sending health check to try and re-open the circuit", extra={"request": change_request_queue_item} - ) - invoke_lambda(lambda_client, change_request_queue_item) - - continue - - logger.append_keys(loop=loop) - response = sqs.receive_message(QueueUrl=QUEUE_URL, MaxNumberOfMessages=10, MessageAttributeNames=["All"]) - messages = response.get("Messages") - if messages is None: - logger.info("No messages at this time") - sleep(1) - else: - logger.info(f"Received {len(messages)} messages from SQS") - - # TODO: Need to figure out circuit breaker bits - for message in messages: - it_start = time() - logger.info("Processing SQS message", extra={"sqs_message": message}) - correlation_id = message["MessageAttributes"]["correlation_id"]["StringValue"] - dynamo_record_id = message["MessageAttributes"]["dynamo_record_id"]["StringValue"] - message_received = int(message["MessageAttributes"]["message_received"]["StringValue"]) - ods_code = message["MessageAttributes"]["ods_code"]["StringValue"] - message_deduplication_id = message["MessageAttributes"]["message_deduplication_id"]["StringValue"] - message_group_id = message["MessageAttributes"]["message_group_id"]["StringValue"] - logger.set_correlation_id(correlation_id) - logger.append_keys(ods_code=ods_code) - s, ms = divmod(message_received, 1000) - message_received_pretty = "%s.%03d" % (strftime("%Y-%m-%d %H:%M:%S", gmtime(s)), ms) - logger.append_keys(message_received=message_received_pretty) - logger.append_keys(dynamo_record_id=dynamo_record_id) - logger.append_keys(ods_code=ods_code) - change_metadata: ChangeMetadata = { - "dynamo_record_id": dynamo_record_id, - "correlation_id": correlation_id, - "message_received": message_received, - "ods_code": ods_code, - "message_deduplication_id": message_deduplication_id, - "message_group_id": message_group_id, - } - change_request_queue_item: ChangeRequestQueueItem = { - "is_health_check": False, - "change_request": extract_body(message["Body"]), - "recipient_id": message["ReceiptHandle"], - "metadata": change_metadata, - } - # TODO: What happens when this fails? - logger.info("Sending request to event sender", extra={"request": change_request_queue_item}) - invoke_lambda(lambda_client, change_request_queue_item) - it_end = time() - to_sleep = max(0, (TIME_TO_SLEEP - (it_end - it_start))) - logger.debug(f"Sleeping for {to_sleep}") - sleep(to_sleep) - loop = loop + 1 - - -def invoke_lambda(lambda_client, payload: Dict[str, Any]) -> Dict[str, Any]: - response = lambda_client.invoke( - FunctionName=getenv("EVENT_SENDER_FUNCTION_NAME"), - InvocationType="Event", - Payload=dumps(payload), - ) - return response diff --git a/application/orchestrator/requirements.txt b/application/orchestrator/requirements.txt deleted file mode 100644 index 56fd45918..000000000 --- a/application/orchestrator/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -aws-lambda-powertools diff --git a/application/orchestrator/tests/test_orchestrator.py b/application/orchestrator/tests/test_orchestrator.py deleted file mode 100644 index e9b8e08f0..000000000 --- a/application/orchestrator/tests/test_orchestrator.py +++ /dev/null @@ -1,247 +0,0 @@ -from dataclasses import dataclass -from json import dumps -from os import environ - -from pytest import fixture, approx -from application.common.types import ChangeMetadata, ChangeRequestQueueItem -from application.orchestrator.orchestrator import invoke_lambda, lambda_handler - -from unittest.mock import Mock, patch - -FILE_PATH = "application.orchestrator.orchestrator" - - -@fixture -def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "cr-fifo-dlq-handler" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:cr-fifo-dlq-handler" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" - - return LambdaContext() - - -LAMBDA_INVOKE_RESPONSE = { - "Payload": "", - "StatusCode": 202, - "ResponseMetadata": {}, -} - - -def test_invoke_lambda(lambda_context): - # Arrange - client_mock = Mock() - environ["EVENT_SENDER_FUNCTION_NAME"] = "MyFirstFunction" - expected = LAMBDA_INVOKE_RESPONSE - client_mock.invoke.return_value = expected - - # Act - payload = {"hello": "dave"} - response = invoke_lambda(client_mock, payload) - # Assert - client_mock.invoke.assert_called_once_with( - FunctionName="MyFirstFunction", InvocationType="Event", Payload=dumps(payload) - ) - assert response == expected - del environ["EVENT_SENDER_FUNCTION_NAME"] - - -EXAMPLE_ATTRIBUTES = { - "correlation_id": {"StringValue": "c1"}, - "dynamo_record_id": {"StringValue": "d1"}, - "message_received": {"StringValue": "1645527100000"}, - "ods_code": {"StringValue": "FA100"}, - "message_deduplication_id": {"StringValue": "dummy_message_deduplication_id"}, - "message_group_id": {"StringValue": "dummy_message_group_id"}, -} - -EXPECTED_METADATA: ChangeMetadata = { - "dynamo_record_id": "d1", - "correlation_id": "c1", - "message_received": "1645527100000", - "ods_code": "FA100", -} -SYSTEM = "DoS Integration" -MESSAGE = "Some message" -EXAMPLE_MESSAGE_1 = { - "reference": "", - "system": SYSTEM, - "message": MESSAGE, - "service_id": "100", - "changes": [], -} -EXAMPLE_MESSAGE_2 = { - "reference": "", - "system": SYSTEM, - "message": MESSAGE, - "service_id": "200", - "changes": [], -} - -EXAMPLE_MESSAGE_3 = { - "reference": "", - "system": SYSTEM, - "message": MESSAGE, - "service_id": "300", - "changes": [], -} - -EXPECTED_HEALTH_CHECK: ChangeRequestQueueItem = { - "is_health_check": True, - "change_request": {}, - "recipient_id": None, - "metadata": None, -} - - -@patch(f"{FILE_PATH}.get_circuit_is_open", return_value=False) -@patch(f"{FILE_PATH}.client") -@patch(f"{FILE_PATH}.invoke_lambda") -@patch(f"{FILE_PATH}.sleep") -@patch( - f"{FILE_PATH}.time", - side_effect=[1645527500, 1645527501, 1645527501, 1645527501.1, 1645527501.5, 1645527501.7, 1645527511], -) -def test_orchestrator_circuit_closed_single_loop(mock_time, mock_sleep, mock_invoke, mock_client, lambda_context): - - # Arrange - environ["RUN_FOR"] = "10" - environ["CIRCUIT"] = "TEST" - environ["SLEEP_FOR_WHEN_OPEN"] = "5" - environ["DOS_TRANSACTIONS_PER_SECOND"] = "2" - mock_invoke.return_value = LAMBDA_INVOKE_RESPONSE - mock_client().invoke.return_value = {} - mock_client().receive_message.return_value = { - "Messages": [ - {"MessageAttributes": EXAMPLE_ATTRIBUTES, "Body": dumps(EXAMPLE_MESSAGE_1), "ReceiptHandle": "H1"}, - {"MessageAttributes": EXAMPLE_ATTRIBUTES, "Body": dumps(EXAMPLE_MESSAGE_2), "ReceiptHandle": "H2"}, - ] - } - - # Act - lambda_handler({}, lambda_context) - - # Assert - assert 2 == mock_invoke.call_count - assert 2 == mock_sleep.call_count - - c0_args, c0_kwargs = mock_sleep.call_args_list[0] - c1_args, c1_kwargs = mock_sleep.call_args_list[1] - assert 0.4 == approx(c0_args[0]) - assert 0.3 == approx(c1_args[0]) - - -@patch(f"{FILE_PATH}.get_circuit_is_open", return_value=False) -@patch(f"{FILE_PATH}.client") -@patch(f"{FILE_PATH}.invoke_lambda") -@patch(f"{FILE_PATH}.sleep") -@patch( - f"{FILE_PATH}.time", - side_effect=[ - 1645527500, - 1645527501, - 1645527501, - 1645527501.1, - 1645527501.5, - 1645527501.7, - 1645527506, - 1645527506, - 1645527506.7, - 1645527511, - ], -) -def test_orchestrator_circuit_closed_double_loop(mock_time, mock_sleep, mock_invoke, mock_client, lambda_context): - - # Arrange - environ["RUN_FOR"] = "10" - environ["CIRCUIT"] = "TEST" - environ["SLEEP_FOR_WHEN_OPEN"] = "5" - environ["DOS_TRANSACTIONS_PER_SECOND"] = "2" - mock_invoke.return_value = LAMBDA_INVOKE_RESPONSE - mock_client().invoke.return_value = {} - mock_client().receive_message.side_effect = [ - { - "Messages": [ - {"MessageAttributes": EXAMPLE_ATTRIBUTES, "Body": dumps(EXAMPLE_MESSAGE_1), "ReceiptHandle": "H1"}, - {"MessageAttributes": EXAMPLE_ATTRIBUTES, "Body": dumps(EXAMPLE_MESSAGE_2), "ReceiptHandle": "H2"}, - ] - }, - { - "Messages": [ - {"MessageAttributes": EXAMPLE_ATTRIBUTES, "Body": dumps(EXAMPLE_MESSAGE_3), "ReceiptHandle": "H3"}, - ] - }, - ] - - # Act - lambda_handler({}, lambda_context) - - # Assert - assert 3 == mock_invoke.call_count - assert 3 == mock_sleep.call_count - - c0_args, c0_kwargs = mock_sleep.call_args_list[0] - c1_args, c1_kwargs = mock_sleep.call_args_list[1] - c2_args, c2_kwargs = mock_sleep.call_args_list[2] - assert 0.4 == approx(c0_args[0]) - assert 0.3 == approx(c1_args[0]) - assert 0 == approx(c2_args[0]) - - -@patch(f"{FILE_PATH}.get_circuit_is_open", return_value=False) -@patch(f"{FILE_PATH}.client") -@patch(f"{FILE_PATH}.invoke_lambda") -@patch(f"{FILE_PATH}.sleep") -@patch(f"{FILE_PATH}.time", side_effect=[1645527500, 1645527501, 1645527511]) -def test_orchestrator_circuit_closed_single_loop_no_messages( - mock_time, mock_sleep, mock_invoke, mock_client, lambda_context -): - # Arrange - environ["RUN_FOR"] = "10" - environ["CIRCUIT"] = "TEST" - environ["SLEEP_FOR_WHEN_OPEN"] = "5" - environ["DOS_TRANSACTIONS_PER_SECOND"] = "2" - mock_invoke.return_value = LAMBDA_INVOKE_RESPONSE - mock_client().invoke.return_value = {} - mock_client().receive_message.return_value = {} - - # Act - lambda_handler({}, lambda_context) - - # Assert - assert 3 == mock_time.call_count - assert 0 == mock_invoke.call_count - assert 1 == mock_sleep.call_count - - mock_sleep.assert_called_once_with(1) - - -@patch(f"{FILE_PATH}.get_circuit_is_open", return_value=True) -@patch(f"{FILE_PATH}.client") -@patch(f"{FILE_PATH}.invoke_lambda") -@patch(f"{FILE_PATH}.sleep") -@patch(f"{FILE_PATH}.time", side_effect=[1645527500, 1645527501, 1645527511]) -def test_orchestrator_circuit_closed_single_loop_circuit_open( - mock_time, mock_sleep, mock_invoke, mock_client, lambda_context -): - # Arrange - environ["RUN_FOR"] = "10" - environ["CIRCUIT"] = "TEST" - environ["SLEEP_FOR_WHEN_OPEN"] = "5" - environ["DOS_TRANSACTIONS_PER_SECOND"] = "2" - mock_invoke.return_value = LAMBDA_INVOKE_RESPONSE - mock_client().invoke.return_value = {} - mock_client().receive_message.return_value = {} - - # Act - lambda_handler({}, lambda_context) - - # Assert - assert 3 == mock_time.call_count - assert 1 == mock_invoke.call_count - assert 1 == mock_sleep.call_count - mock_invoke.assert_called_once_with(mock_client(), EXPECTED_HEALTH_CHECK) - - mock_sleep.assert_called_once_with(5) diff --git a/application/pyproject.toml b/application/pyproject.toml new file mode 100644 index 000000000..c6d11ad45 --- /dev/null +++ b/application/pyproject.toml @@ -0,0 +1,19 @@ +[tool.vulture] +make_whitelist = true +paths = ["."] +exclude = ["/tests", "types.py", "conftest.py"] +sort_by_size = true +min_confidence = 60 +ignore_names = [ + "email_body", + "email_subject", + "encoding", + "field_names", + "is_health_check", + "is_matching_dos_service", + "lambda_context", + "lambda_handler", + "metadata", + "recipient_email_address", + "recipient_id", +] diff --git a/application/event_processor/__init__.py b/application/quality_checker/__init__.py similarity index 100% rename from application/event_processor/__init__.py rename to application/quality_checker/__init__.py diff --git a/application/quality_checker/check_dos.py b/application/quality_checker/check_dos.py new file mode 100644 index 000000000..2821c7e8b --- /dev/null +++ b/application/quality_checker/check_dos.py @@ -0,0 +1,91 @@ +from aws_lambda_powertools.logging import Logger +from psycopg import Connection + +from .reporting import log_to_quality_check_report +from .search_dos import ( + search_for_incorrectly_profiled_z_code_on_correct_type, + search_for_incorrectly_profiled_z_code_on_incorrect_type, + search_for_matching_services, + search_for_pharmacy_ods_codes, +) +from common.commissioned_service_type import BLOOD_PRESSURE, CONTRACEPTION, PALLIATIVE_CARE, CommissionedServiceType +from common.dos import DoSService + +logger = Logger(child=True) + + +def check_pharmacy_profiling(connection: Connection) -> None: + """Check the pharmacy profiling data quality of the dos database. + + Args: + connection (Connection): Connection to the DoS DB. + """ + odscodes = search_for_pharmacy_ods_codes(connection) + for odscode in odscodes: + logger.append_keys(odscode=odscode) + logger.info(f"Checking pharmacy profiling for odscode '{odscode}'.") + matched_services = search_for_matching_services(connection, odscode) + check_for_multiple_of_service_type(matched_services, BLOOD_PRESSURE) + check_for_multiple_of_service_type(matched_services, CONTRACEPTION) + logger.remove_keys("odscode") + + +def check_for_zcode_profiling_on_incorrect_type(connection: Connection, service_type: CommissionedServiceType) -> None: + """Check the zcode profiling data quality of the dos database. + + Args: + connection (Connection): Connection to the DoS DB. + service_type (CommissionedServiceType): Service type to check for. + """ + if incorrectly_profiled_services := search_for_incorrectly_profiled_z_code_on_incorrect_type( + connection, + service_type, + ): + log_to_quality_check_report( + incorrectly_profiled_services, + f"{service_type.TYPE_NAME} ZCode is on invalid service type", + service_type.DOS_SG_SD_ID, + ) + + +def check_for_palliative_care_profiling(connection: Connection) -> None: + """Check the zcode profiling data quality of the dos database. + + Args: + connection (Connection): Connection to the DoS DB. + service_type (CommissionedServiceType): Service type to check for. + """ + check_for_zcode_profiling_on_incorrect_type(connection, PALLIATIVE_CARE) + if incorrectly_profiled_services := search_for_incorrectly_profiled_z_code_on_correct_type( + connection, + PALLIATIVE_CARE, + ): + logger.info( + f"Found {len(incorrectly_profiled_services)} incorrectly " + f"profiled {PALLIATIVE_CARE.TYPE_NAME.lower()} services.", + services=incorrectly_profiled_services, + ) + log_to_quality_check_report( + incorrectly_profiled_services, + f"{PALLIATIVE_CARE.TYPE_NAME} ZCode is on the correct service type, " + "but the service is incorrectly profiled", + PALLIATIVE_CARE.DOS_SG_SD_ID, + ) + + +def check_for_multiple_of_service_type( + matched_services: list[DoSService], + service_type: CommissionedServiceType, +) -> None: + """Check for multiple of service type. + + Args: + matched_services (list[DoSService]): List of matched services. + service_type (CommissionedServiceType): Service type to check for. + """ + matched_service_types = [service for service in matched_services if service.typeid == service_type.DOS_TYPE_ID] + if len(matched_service_types) > 1: + log_to_quality_check_report( + matched_service_types, + f"Multiple 'Pharmacy' type services found (type {service_type.DOS_TYPE_ID})", + ) diff --git a/application/quality_checker/quality_checker.py b/application/quality_checker/quality_checker.py new file mode 100644 index 000000000..c4683f82c --- /dev/null +++ b/application/quality_checker/quality_checker.py @@ -0,0 +1,57 @@ +from os import getenv + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.tracing import Tracer +from aws_lambda_powertools.utilities.data_classes import EventBridgeEvent, event_source +from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext + +from .check_dos import ( + check_for_palliative_care_profiling, + check_for_zcode_profiling_on_incorrect_type, + check_pharmacy_profiling, +) +from common.commissioned_service_type import BLOOD_PRESSURE, CONTRACEPTION +from common.dos_db_connection import connect_to_db_reader +from common.middlewares import unhandled_exception_logging + +logger = Logger() +tracer = Tracer() + + +@tracer.capture_lambda_handler() +@logger.inject_lambda_context(clear_state=True) +@unhandled_exception_logging +@event_source(data_class=EventBridgeEvent) +def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> None: # noqa: ARG001 + """Lambda handler for quality checker.""" + try: + logger.info("Quality checker started") + check_dos_data_quality() + logger.warning( + "Quality checker finished", + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="QualityCheckerFinished", + ) + except Exception: + logger.exception( + "Quality checker Errored", + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="QualityCheckerErrored", + ) + raise + + +def check_dos_data_quality() -> None: + """Check the data quality of the dos database.""" + with connect_to_db_reader() as db_connection: + # Checks matched odscode services for pharmacy profiling + check_pharmacy_profiling(db_connection) + + # Checks matched odscode services for incorrectly profiled palliative care + check_for_palliative_care_profiling(db_connection) + + # Checks matched odscode services for incorrectly profiled blood pressure + check_for_zcode_profiling_on_incorrect_type(db_connection, BLOOD_PRESSURE) + + # Checks matched odscode services for incorrectly profiled contraception + check_for_zcode_profiling_on_incorrect_type(db_connection, CONTRACEPTION) diff --git a/application/quality_checker/reporting.py b/application/quality_checker/reporting.py new file mode 100644 index 000000000..9988a35ca --- /dev/null +++ b/application/quality_checker/reporting.py @@ -0,0 +1,39 @@ +from os import getenv + +from aws_lambda_powertools.logging import Logger + +from common.dos import DoSService + +QUALITY_CHECK_REPORT_KEY = "QUALITY_CHECK_REPORT_KEY" + +logger = Logger(child=True) + + +def log_to_quality_check_report( + matched_services: list[DoSService], + reason: str, + z_code: str = "", +) -> None: + """Log a service to the quality check report. + + Args: + matched_services (list[DoSService]): The DoS service to report + reason (str): The reason for the report + z_code (str): The z-code for the report + """ + for service in matched_services: + logger.warning( + reason, + report_key=QUALITY_CHECK_REPORT_KEY, + dos_service_uid=service.uid, + dos_service_odscode=service.odscode, + dos_service_name=service.name, + dos_service_type_name=service.service_type_name, + dos_service_type_id=service.typeid, + dos_region=service.get_region(), + z_code=z_code, + reason=reason, + odscode=service.odscode[:5], + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="QualityCheckerIssueFound", + ) diff --git a/application/quality_checker/requirements.txt b/application/quality_checker/requirements.txt new file mode 100644 index 000000000..39387a7d8 --- /dev/null +++ b/application/quality_checker/requirements.txt @@ -0,0 +1,2 @@ +aws-lambda-powertools[tracer, validation] ~= 2.43.0 +psycopg[binary] diff --git a/application/quality_checker/search_dos.py b/application/quality_checker/search_dos.py new file mode 100644 index 000000000..8b16a49b1 --- /dev/null +++ b/application/quality_checker/search_dos.py @@ -0,0 +1,154 @@ +from os import getenv + +from aws_lambda_powertools.logging import Logger +from psycopg import Connection + +from common.commissioned_service_type import CommissionedServiceType +from common.constants import DOS_ACTIVE_STATUS_ID, PHARMACY_SERVICE_TYPE_IDS +from common.dos import DoSService +from common.dos_db_connection import query_dos_db + +logger = Logger(child=True) + + +def search_for_pharmacy_ods_codes(connection: Connection) -> set[str]: + """Search for pharmacy ODS codes in DoS DB. + + Args: + connection (Connection): Connection to the DoS DB. + + Returns: + set[str]: Unique set of pharmacy ODS codes. + """ + starting_character = getenv("ODSCODE_STARTING_CHARACTER") or "f" + cursor = query_dos_db( + connection, + "SELECT LEFT(odscode, 5) FROM services s WHERE s.typeid = ANY(%(PHARMACY_SERVICE_TYPE_IDS)s) " + "AND s.statusid = %(ACTIVE_STATUS_ID)s AND LEFT(REPLACE(TRIM(odscode), CHR(9), ''), 1) IN " + "(%(ODSCODE_STARTING_CHARACTER_CAPITALISED)s, %(ODSCODE_STARTING_CHARACTER)s)", + { + "PHARMACY_SERVICE_TYPE_IDS": PHARMACY_SERVICE_TYPE_IDS, + "ACTIVE_STATUS_ID": DOS_ACTIVE_STATUS_ID, + "ODSCODE_STARTING_CHARACTER_CAPITALISED": starting_character.upper(), + "ODSCODE_STARTING_CHARACTER": starting_character.lower(), + }, + ) + odscodes = {odscode_row["left"] for odscode_row in cursor.fetchall()} + cursor.close() + logger.info(f"Found {len(odscodes)} pharmacy ODS codes.", odscodes=odscodes) + return odscodes + + +def search_for_matching_services(connection: Connection, odscode: str) -> list[DoSService]: + """Search for matching services in DoS DB using odscode. + + Args: + connection (Connection): Connection to the DoS DB. + odscode (str): Search for matching services using this odscode. + + Returns: + list[DoSService]: List of matching services. + """ + cursor = query_dos_db( + connection, + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid," + "statusid, ss.name status_name, publicphone, publicname, st.name service_type_name " + "FROM services s LEFT JOIN servicetypes st ON s.typeid = st.id " + "LEFT JOIN servicestatuses ss on s.statusid = ss.id " + "WHERE s.odscode LIKE %(ODSCODE)s AND s.statusid = %(ACTIVE_STATUS_ID)s " + "AND s.typeid = ANY(%(PHARMACY_SERVICE_TYPE_IDS)s)", + { + "ODSCODE": f"{odscode}%", + "ACTIVE_STATUS_ID": DOS_ACTIVE_STATUS_ID, + "PHARMACY_SERVICE_TYPE_IDS": PHARMACY_SERVICE_TYPE_IDS, + }, + ) + services = [DoSService(row) for row in cursor.fetchall()] + cursor.close() + logger.info(f"Found {len(services)} active matching services.", services=services) + return services + + +def search_for_incorrectly_profiled_z_code_on_incorrect_type( + connection: Connection, + service_type: CommissionedServiceType, +) -> list[DoSService]: + """Search for incorrectly profiled services in DoS DB on wrong service type. + + Args: + connection (Connection): Connection to the DoS DB. + service_type (CommissionedServiceType): Service type to check for. + + Returns: + list[DoSService]: List of matching services. + """ + matchable_service_types = PHARMACY_SERVICE_TYPE_IDS.copy() + matchable_service_types.remove(service_type.DOS_TYPE_ID) + starting_character = getenv("ODSCODE_STARTING_CHARACTER") or "f" + cursor = query_dos_db( + connection, + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid, statusid, ss.name status_name, " + "publicphone, publicname, st.name service_type_name " + "FROM services s LEFT JOIN servicetypes st ON s.typeid = st.id " + "LEFT JOIN servicestatuses ss on s.statusid = ss.id " + "LEFT JOIN servicesgsds sgsds on s.id = sgsds.serviceid " + "WHERE sgsds.sgid = %(SYMPTOM_GROUP)s AND sgsds.sdid = %(SYMPTOM_DISCRIMINATOR)s " + "AND s.statusid = %(ACTIVE_STATUS_ID)s AND s.typeid = ANY(%(SERVICE_TYPE_IDS)s) " + "AND LEFT(s.odscode,1) in (%(ODSCODE_STARTING_CHARACTER_CAPITALISED)s, %(ODSCODE_STARTING_CHARACTER)s)", + { + "ACTIVE_STATUS_ID": DOS_ACTIVE_STATUS_ID, + "SERVICE_TYPE_IDS": matchable_service_types, + "SYMPTOM_GROUP": service_type.DOS_SYMPTOM_GROUP, + "SYMPTOM_DISCRIMINATOR": service_type.DOS_SYMPTOM_DISCRIMINATOR, + "ODSCODE_STARTING_CHARACTER_CAPITALISED": starting_character.upper(), + "ODSCODE_STARTING_CHARACTER": starting_character.lower(), + }, + ) + services = [DoSService(row) for row in cursor.fetchall()] + cursor.close() + logger.info( + f"Found {len(services)} {service_type.TYPE_NAME} active offending services on incorrect type .", + matchable_service_types=matchable_service_types, + services=services, + ) + return services + + +def search_for_incorrectly_profiled_z_code_on_correct_type( + connection: Connection, + service_type: CommissionedServiceType, +) -> list[DoSService]: + """Search for incorrectly profiled services in DoS DB on correct service type. + + Args: + connection (Connection): Connection to the DoS DB. + service_type (CommissionedServiceType): Service type to check for. + + Returns: + list[DoSService]: List of matching services. + """ + starting_character = getenv("ODSCODE_STARTING_CHARACTER") or "f" + cursor = query_dos_db( + connection, + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid, statusid, ss.name status_name, " + "publicphone, publicname, st.name service_type_name " + "FROM services s LEFT JOIN servicetypes st ON s.typeid = st.id " + "LEFT JOIN servicestatuses ss on s.statusid = ss.id " + "LEFT JOIN servicesgsds sgsds on s.id = sgsds.serviceid " + "WHERE sgsds.sgid = %(SYMPTOM_GROUP)s AND sgsds.sdid = %(SYMPTOM_DISCRIMINATOR)s " + "AND s.statusid = %(ACTIVE_STATUS_ID)s AND s.typeid = ANY(%(SERVICE_TYPE_IDS)s) " + "AND LEFT(s.odscode,1) in (%(ODSCODE_STARTING_CHARACTER_CAPITALISED)s, %(ODSCODE_STARTING_CHARACTER)s)" + "AND LENGTH(s.odscode) > 5", + { + "ACTIVE_STATUS_ID": DOS_ACTIVE_STATUS_ID, + "SERVICE_TYPE_IDS": [service_type.DOS_TYPE_ID], + "SYMPTOM_GROUP": service_type.DOS_SYMPTOM_GROUP, + "SYMPTOM_DISCRIMINATOR": service_type.DOS_SYMPTOM_DISCRIMINATOR, + "ODSCODE_STARTING_CHARACTER_CAPITALISED": starting_character.upper(), + "ODSCODE_STARTING_CHARACTER": starting_character.lower(), + }, + ) + services = [DoSService(row) for row in cursor.fetchall()] + cursor.close() + logger.info(f"Found {len(services)} active offending services on correct type.", services=services) + return services diff --git a/application/event_sender/__init__.py b/application/quality_checker/tests/__init__.py similarity index 100% rename from application/event_sender/__init__.py rename to application/quality_checker/tests/__init__.py diff --git a/application/quality_checker/tests/test_check_dos.py b/application/quality_checker/tests/test_check_dos.py new file mode 100644 index 000000000..f18e02306 --- /dev/null +++ b/application/quality_checker/tests/test_check_dos.py @@ -0,0 +1,106 @@ +from unittest.mock import MagicMock, call, patch + +from application.quality_checker.check_dos import ( + check_for_multiple_of_service_type, + check_for_zcode_profiling_on_incorrect_type, + check_pharmacy_profiling, +) +from common.commissioned_service_type import BLOOD_PRESSURE, CONTRACEPTION + +FILE_PATH = "application.quality_checker.check_dos" + + +@patch(f"{FILE_PATH}.check_for_multiple_of_service_type") +@patch(f"{FILE_PATH}.search_for_matching_services") +@patch(f"{FILE_PATH}.search_for_pharmacy_ods_codes") +def test_check_pharmacy_profiling( + mock_search_for_pharmacy_ods_codes: MagicMock, + mock_search_for_matching_services: MagicMock, + mock_check_for_multiple_of_service_type: MagicMock, +) -> None: + # Arrange + connection = MagicMock() + odscode = "ABC123" + mock_search_for_pharmacy_ods_codes.return_value = [odscode] + # Act + check_pharmacy_profiling(connection) + # Assert + mock_search_for_pharmacy_ods_codes.assert_called_once_with(connection) + mock_search_for_matching_services.assert_called_once_with(connection, odscode) + mock_check_for_multiple_of_service_type.assert_has_calls( + calls=[ + call(mock_search_for_matching_services.return_value, BLOOD_PRESSURE), + call(mock_search_for_matching_services.return_value, CONTRACEPTION), + ], + ) + + +@patch(f"{FILE_PATH}.log_to_quality_check_report") +@patch(f"{FILE_PATH}.search_for_incorrectly_profiled_z_code_on_incorrect_type") +def test_check_for_zcode_profiling_on_incorrect_type( + mock_search_for_incorrectly_profiled_z_code_on_incorrect_type: MagicMock, + mock_log_to_quality_check_report: MagicMock, +) -> None: + # Arrange + connection = MagicMock() + service = MagicMock() + matched_services = [service] + mock_search_for_incorrectly_profiled_z_code_on_incorrect_type.return_value = matched_services + # Act + check_for_zcode_profiling_on_incorrect_type(connection, BLOOD_PRESSURE) + # Assert + mock_search_for_incorrectly_profiled_z_code_on_incorrect_type.assert_called_once_with(connection, BLOOD_PRESSURE) + mock_log_to_quality_check_report.assert_called_once_with( + matched_services, + "Blood Pressure ZCode is on invalid service type", + BLOOD_PRESSURE.DOS_SG_SD_ID, + ) + + +@patch(f"{FILE_PATH}.log_to_quality_check_report") +@patch(f"{FILE_PATH}.search_for_incorrectly_profiled_z_code_on_incorrect_type") +def test_check_for_zcode_profiling_on_incorrect_type_no_matches( + mock_search_for_incorrectly_profiled_z_code_on_incorrect_type: MagicMock, + mock_log_to_quality_check_report: MagicMock, +) -> None: + # Arrange + connection = MagicMock() + matched_services = [] + mock_search_for_incorrectly_profiled_z_code_on_incorrect_type.return_value = matched_services + # Act + check_for_zcode_profiling_on_incorrect_type(connection, BLOOD_PRESSURE) + # Assert + mock_search_for_incorrectly_profiled_z_code_on_incorrect_type.assert_called_once_with(connection, BLOOD_PRESSURE) + mock_log_to_quality_check_report.assert_not_called() + + +@patch(f"{FILE_PATH}.log_to_quality_check_report") +def test_check_for_multiple_of_service_type(mock_log_to_quality_check_report: MagicMock) -> None: + # Arrange + to_be_matched_services = [ + MagicMock(typeid=BLOOD_PRESSURE.DOS_TYPE_ID), + MagicMock(typeid=BLOOD_PRESSURE.DOS_TYPE_ID), + ] + not_to_be_matched_services = [ + MagicMock(typeid=CONTRACEPTION.DOS_TYPE_ID), + ] + matched_services = to_be_matched_services + not_to_be_matched_services + # Act + check_for_multiple_of_service_type(matched_services, BLOOD_PRESSURE) + # Assert + mock_log_to_quality_check_report.assert_called_once_with( + to_be_matched_services, + "Multiple 'Pharmacy' type services found (type 148)", + ) + + +@patch(f"{FILE_PATH}.log_to_quality_check_report") +def test_check_for_multiple_of_service_type_no_matches(mock_log_to_quality_check_report: MagicMock) -> None: + # Arrange + not_to_be_matched_services = [ + MagicMock(typeid=CONTRACEPTION.DOS_TYPE_ID), + ] + # Act + check_for_multiple_of_service_type(not_to_be_matched_services, BLOOD_PRESSURE) + # Assert + mock_log_to_quality_check_report.assert_not_called() diff --git a/application/quality_checker/tests/test_quality_checker.py b/application/quality_checker/tests/test_quality_checker.py new file mode 100644 index 000000000..d4ee10573 --- /dev/null +++ b/application/quality_checker/tests/test_quality_checker.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass +from unittest.mock import MagicMock, call, patch + +import pytest +from aws_lambda_powertools.utilities.typing import LambdaContext + +from application.quality_checker.quality_checker import ( + check_dos_data_quality, + lambda_handler, +) +from common.commissioned_service_type import BLOOD_PRESSURE, CONTRACEPTION + +FILE_PATH = "application.quality_checker.quality_checker" + + +@pytest.fixture() +def lambda_context() -> None: + @dataclass + class LambdaContext: + """Mock LambdaContext - All dummy values.""" + + function_name: str = "quality-checker" + memory_limit_in_mb: int = 128 + invoked_function_arn: str = "arn:aws:lambda:eu-west-1:000000000:function:quality-checker" + aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" + + return LambdaContext() + + +@patch(f"{FILE_PATH}.check_dos_data_quality") +def test_lambda_handler( + mock_check_dos_data_quality: MagicMock, + lambda_context: LambdaContext, +) -> None: + # Arrange + event = {} + # Act + lambda_handler(event, lambda_context) + # Assert + mock_check_dos_data_quality.assert_called_once_with() + + +@patch(f"{FILE_PATH}.check_for_zcode_profiling_on_incorrect_type") +@patch(f"{FILE_PATH}.check_pharmacy_profiling") +@patch(f"{FILE_PATH}.connect_to_db_reader") +def test_check_dos_data_quality( + mock_connect_to_db_reader: MagicMock, + mock_check_pharmacy_profiling: MagicMock, + mock_check_for_zcode_profiling_on_incorrect_type: MagicMock, +) -> None: + # Arrange + # Act + check_dos_data_quality() + # Assert + mock_connect_to_db_reader.assert_called_once_with() + mock_check_pharmacy_profiling.assert_called_once_with( + mock_connect_to_db_reader().__enter__(), + ) + mock_check_for_zcode_profiling_on_incorrect_type.assert_has_calls( + calls=[ + call(mock_connect_to_db_reader().__enter__(), BLOOD_PRESSURE), + call(mock_connect_to_db_reader().__enter__(), CONTRACEPTION), + ], + ) diff --git a/application/quality_checker/tests/test_reporting.py b/application/quality_checker/tests/test_reporting.py new file mode 100644 index 000000000..fdbcac756 --- /dev/null +++ b/application/quality_checker/tests/test_reporting.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock, patch + +from aws_lambda_powertools.logging import Logger + +from application.quality_checker.reporting import log_to_quality_check_report + +FILE_PATH = "application.quality_checker.reporting" + + +@patch.object(Logger, "warning") +def test_log_to_quality_check_report(mock_warning_logger: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + matched_services = [ + dos_service, + ] + reason = "reason" + # Act + log_to_quality_check_report(matched_services, reason) + # Assert + mock_warning_logger.assert_called_once_with( + reason, + report_key="QUALITY_CHECK_REPORT_KEY", + dos_service_uid=dos_service.uid, + dos_service_odscode=dos_service.odscode, + dos_service_name=dos_service.name, + dos_service_type_name=dos_service.service_type_name, + dos_service_type_id=dos_service.typeid, + dos_region=dos_service.get_region(), + z_code="", + reason=reason, + odscode=dos_service.odscode[:5], + environment="local", + cloudwatch_metric_filter_matching_attribute="QualityCheckerIssueFound", + ) diff --git a/application/quality_checker/tests/test_search_dos.py b/application/quality_checker/tests/test_search_dos.py new file mode 100644 index 000000000..783846580 --- /dev/null +++ b/application/quality_checker/tests/test_search_dos.py @@ -0,0 +1,155 @@ +from datetime import UTC, datetime +from typing import Any +from unittest.mock import MagicMock, patch + +from application.quality_checker.search_dos import ( + search_for_incorrectly_profiled_z_code_on_correct_type, + search_for_incorrectly_profiled_z_code_on_incorrect_type, + search_for_matching_services, + search_for_pharmacy_ods_codes, +) +from common.commissioned_service_type import BLOOD_PRESSURE, PALLIATIVE_CARE +from common.constants import DOS_ACTIVE_STATUS_ID, PHARMACY_SERVICE_TYPE_IDS +from common.dos import DoSService + +FILE_PATH = "application.quality_checker.search_dos" + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_search_for_pharmacy_ods_codes(mock_query_dos_db: MagicMock) -> None: + # Arrange + connection = MagicMock() + odscode = "ABC123" + mock_query_dos_db.return_value.fetchall.return_value = [{"left": odscode}] + # Act + response = search_for_pharmacy_ods_codes(connection) + # Assert + assert response == {odscode} + mock_query_dos_db.assert_called_once_with( + connection, + "SELECT LEFT(odscode, 5) FROM services s WHERE s.typeid = ANY(%(PHARMACY_SERVICE_TYPE_IDS)s) AND s.statusid = " + "%(ACTIVE_STATUS_ID)s AND LEFT(REPLACE(TRIM(odscode), CHR(9), ''), 1) IN " + "(%(ODSCODE_STARTING_CHARACTER_CAPITALISED)s, %(ODSCODE_STARTING_CHARACTER)s)", + { + "PHARMACY_SERVICE_TYPE_IDS": [13, 131, 132, 134, 137, 148, 149], + "ACTIVE_STATUS_ID": 1, + "ODSCODE_STARTING_CHARACTER_CAPITALISED": "F", + "ODSCODE_STARTING_CHARACTER": "f", + }, + ) + + +def get_service_data() -> dict[str, Any]: + return { + "id": 9999, + "uid": "159514725", + "name": "fake name", + "odscode": "FA9321", + "address": "80 Street$Town", + "town": "Town", + "postcode": "TES T12", + "web": None, + "email": None, + "fax": None, + "nonpublicphone": None, + "typeid": 13, + "parentid": 123486, + "subregionid": 21813557, + "statusid": 1, + "createdtime": datetime(2011, 8, 24, 9, 17, 24, tzinfo=UTC), + "modifiedtime": datetime(2019, 3, 13, 0, 37, 7, tzinfo=UTC), + "publicphone": "0123 012 012", + "publicname": None, + "service_type_name": "my service", + "status_name": "Active", + "easting": "123456", + "northing": "123456", + "latitude": "123456", + "longitude": "123456", + } + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_search_for_matching_services(mock_query_dos_db: MagicMock) -> None: + # Arrange + connection = MagicMock() + odscode = "ABC12" + service = get_service_data() + dos_service = DoSService(service) + mock_query_dos_db.return_value.fetchall.return_value = [service] + # Act + response = search_for_matching_services(connection, odscode) + # Assert + assert response == [dos_service] + mock_query_dos_db.assert_called_once_with( + connection, + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid,statusid, ss.name status_name, publicphone, " + "publicname, st.name service_type_name FROM services s LEFT JOIN servicetypes st ON s.typeid = st.id LEFT JOIN " + "servicestatuses ss on s.statusid = ss.id WHERE s.odscode LIKE %(ODSCODE)s AND s.statusid = " + "%(ACTIVE_STATUS_ID)s AND s.typeid = ANY(%(PHARMACY_SERVICE_TYPE_IDS)s)", + { + "ODSCODE": f"{odscode}%", + "ACTIVE_STATUS_ID": DOS_ACTIVE_STATUS_ID, + "PHARMACY_SERVICE_TYPE_IDS": PHARMACY_SERVICE_TYPE_IDS, + }, + ) + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_search_for_incorrectly_profiled_z_code_on_incorrect_type(mock_query_dos_db: MagicMock) -> None: + # Arrange + connection = MagicMock() + service = get_service_data() + dos_service = DoSService(service) + mock_query_dos_db.return_value.fetchall.return_value = [service] + # Act + response = search_for_incorrectly_profiled_z_code_on_incorrect_type(connection, BLOOD_PRESSURE) + # Assert + assert response == [dos_service] + mock_query_dos_db.assert_called_once_with( + connection, + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid, statusid, ss.name status_name, " + "publicphone, publicname, st.name service_type_name FROM services s LEFT JOIN servicetypes st ON " + "s.typeid = st.id LEFT JOIN servicestatuses ss on s.statusid = ss.id LEFT JOIN servicesgsds sgsds on s.id = " + "sgsds.serviceid WHERE sgsds.sgid = %(SYMPTOM_GROUP)s AND sgsds.sdid = %(SYMPTOM_DISCRIMINATOR)s AND " + "s.statusid = %(ACTIVE_STATUS_ID)s AND s.typeid = ANY(%(SERVICE_TYPE_IDS)s) AND LEFT(s.odscode,1) in " + "(%(ODSCODE_STARTING_CHARACTER_CAPITALISED)s, %(ODSCODE_STARTING_CHARACTER)s)", + { + "ACTIVE_STATUS_ID": 1, + "SERVICE_TYPE_IDS": [13, 131, 132, 134, 137, 149], + "SYMPTOM_GROUP": 360, + "SYMPTOM_DISCRIMINATOR": 14207, + "ODSCODE_STARTING_CHARACTER_CAPITALISED": "F", + "ODSCODE_STARTING_CHARACTER": "f", + }, + ) + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_search_for_incorrectly_profiled_z_code_on_correct_type(mock_query_dos_db: MagicMock) -> None: + # Arrange + connection = MagicMock() + service = get_service_data() + dos_service = DoSService(service) + mock_query_dos_db.return_value.fetchall.return_value = [service] + # Act + response = search_for_incorrectly_profiled_z_code_on_correct_type(connection, PALLIATIVE_CARE) + # Assert + assert response == [dos_service] + mock_query_dos_db.assert_called_once_with( + connection, + "SELECT s.id, uid, s.name, odscode, address, postcode, web, typeid, statusid, ss.name status_name, " + "publicphone, publicname, st.name service_type_name FROM services s LEFT JOIN servicetypes st ON s.typeid = " + "st.id LEFT JOIN servicestatuses ss on s.statusid = ss.id LEFT JOIN servicesgsds sgsds on s.id = " + "sgsds.serviceid WHERE sgsds.sgid = %(SYMPTOM_GROUP)s AND sgsds.sdid = %(SYMPTOM_DISCRIMINATOR)s AND " + "s.statusid = %(ACTIVE_STATUS_ID)s AND s.typeid = ANY(%(SERVICE_TYPE_IDS)s) AND LEFT(s.odscode,1) in " + "(%(ODSCODE_STARTING_CHARACTER_CAPITALISED)s, %(ODSCODE_STARTING_CHARACTER)s)AND LENGTH(s.odscode) > 5", + { + "ACTIVE_STATUS_ID": 1, + "SERVICE_TYPE_IDS": [13], + "SYMPTOM_GROUP": 360, + "SYMPTOM_DISCRIMINATOR": 14167, + "ODSCODE_STARTING_CHARACTER_CAPITALISED": "F", + "ODSCODE_STARTING_CHARACTER": "f", + }, + ) diff --git a/application/requirements-dev.txt b/application/requirements-dev.txt index 3cef476e3..c8680370d 100644 --- a/application/requirements-dev.txt +++ b/application/requirements-dev.txt @@ -1,18 +1,20 @@ Faker aws-lambda-context boto3 -coverage locust moto -mutmut pandas pytest pytest-bdd pytest-cov +pytest-flakefinder pytest-html pytest-icdiff +pytest-replay +pytest-rerunfailures pytest-sugar pytest-xdist requests -responses +ruff == 0.5.6 testfixtures +vulture diff --git a/application/fifo_dlq_handler/__init__.py b/application/send_email/__init__.py similarity index 100% rename from application/fifo_dlq_handler/__init__.py rename to application/send_email/__init__.py diff --git a/application/send_email/requirements.txt b/application/send_email/requirements.txt new file mode 100644 index 000000000..e27f685d9 --- /dev/null +++ b/application/send_email/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools[tracer] ~= 2.43.0 diff --git a/application/send_email/send_email.py b/application/send_email/send_email.py new file mode 100644 index 000000000..6a19538dc --- /dev/null +++ b/application/send_email/send_email.py @@ -0,0 +1,75 @@ +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from os import environ +from smtplib import SMTP, SMTPException + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.tracing import Tracer +from aws_lambda_powertools.utilities.typing import LambdaContext + +from common.middlewares import unhandled_exception_logging_hidden_event +from common.secretsmanager import get_secret +from common.types import EmailMessage + +tracer = Tracer() +logger = Logger() + + +@tracer.capture_lambda_handler() +@unhandled_exception_logging_hidden_event +@logger.inject_lambda_context(clear_state=True, correlation_id_path="correlation_id") +def lambda_handler(event: EmailMessage, context: LambdaContext) -> None: # noqa: ARG001 + """Entrypoint handler for the service_sync lambda. + + Args: + event (EmailMessage): Lambda function invocation event + context (LambdaContext): Lambda function context object + """ + logger.append_keys(user_id=event["user_id"], change_id=event["change_id"], s3_filename=event["s3_filename"]) + logger.info("Starting send_email lambda") + send_email( + email_address=event["recipient_email_address"], + html_content=event["email_body"], + subject=event["email_subject"], + correlation_id=event["correlation_id"], + ) + + +def send_email(email_address: str, html_content: str, subject: str, correlation_id: str) -> None: + """Send an email to the specified email address. + + Args: + email_address (str): Email address to send the email to + html_content (str): HTML content of the email + subject (str): Subject of the email + correlation_id (str): Correlation ID of the email + """ + aws_account_name = environ["AWS_ACCOUNT_NAME"] + if aws_account_name != "nonprod" or "email" in correlation_id: + logger.info("Preparing to send email") + email_secrets = get_secret(environ["EMAIL_SECRET_NAME"]) + to_email_address = email_address + di_system_email_address = email_secrets["DI_SYSTEM_MAILBOX_ADDRESS"] + di_system_email_password = email_secrets["DI_SYSTEM_MAILBOX_PASSWORD"] + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg.attach(MIMEText(html_content, "html")) + logger.info("Email content prepared") + try: + # Don't log any variables that contain PID or password + smtp = SMTP(host="smtp.office365.com", port=587, timeout=15) + logger.info("Connected to SMTP server") + smtp.ehlo() + logger.info("Sent EHLO") + smtp.starttls() + logger.info("Started TLS") + smtp.login(di_system_email_address, di_system_email_password) + logger.info("Logged in to SMTP server") + smtp.sendmail(from_addr=di_system_email_address, to_addrs=[to_email_address], msg=msg.as_string()) + logger.warning("Sent email", cloudwatch_metric_filter_matching_attribute="EmailSent") + smtp.quit() + logger.info("Disconnected from SMTP server") + except BaseException: + logger.exception("Email failed", cloudwatch_metric_filter_matching_attribute="EmailFailed") + msg = "An error occurred while sending the email" + raise SMTPException(msg) from None diff --git a/application/fifo_dlq_handler/tests/__init__.py b/application/send_email/tests/__init__.py similarity index 100% rename from application/fifo_dlq_handler/tests/__init__.py rename to application/send_email/tests/__init__.py diff --git a/application/send_email/tests/test_send_email.py b/application/send_email/tests/test_send_email.py new file mode 100644 index 000000000..aa1abb91e --- /dev/null +++ b/application/send_email/tests/test_send_email.py @@ -0,0 +1,149 @@ +from os import environ +from smtplib import SMTPException +from unittest.mock import MagicMock, patch + +import pytest +from aws_lambda_powertools.utilities.typing import LambdaContext + +from application.send_email.send_email import lambda_handler, send_email +from common.types import EmailMessage + +FILE_PATH = "application.send_email.send_email" +BUCKET = "bucket" +KEY = "key" +CORRELATION_ID = "correlation_id" +RECIPIENT_EMAIL_ADDRESS = "recipient_email_address" +EMAIL_BODY = "This is the email body" +EMAIL_SUBJECT = "Subject of email" +EVENT = EmailMessage( + correlation_id=CORRELATION_ID, + recipient_email_address=RECIPIENT_EMAIL_ADDRESS, + email_body=EMAIL_BODY, + email_subject=EMAIL_SUBJECT, + user_id="user_id", + change_id="change_id", + s3_filename="s3_filename", +) + + +@patch(f"{FILE_PATH}.send_email") +def test_lambda_handler(mock_send_email: MagicMock, lambda_context: LambdaContext) -> None: + # Arrange + event = EVENT.copy() + # Act + response = lambda_handler(event, lambda_context) + # Assert + assert response is None + mock_send_email.assert_called_once_with( + email_address=event["recipient_email_address"], + html_content=event["email_body"], + subject=event["email_subject"], + correlation_id=event["correlation_id"], + ) + + +@patch(f"{FILE_PATH}.MIMEMultipart") +@patch(f"{FILE_PATH}.SMTP") +@patch(f"{FILE_PATH}.get_secret") +def test_send_email( + mock_get_secret: MagicMock, + mock_smtp: MagicMock, + mock_mime_multipart: MagicMock, +) -> None: + # Arrange + environ["AWS_ACCOUNT_NAME"] = "test" + environ["EMAIL_SECRET_NAME"] = secret_name = "mock_secret_name" + di_team_mailbox_address = "di_team_mailbox_address" + di_system_mailbox_address = "di_system_mailbox_address" + di_system_mailbox_password = "di_system_mailbox_password" + mock_get_secret.return_value = { + "DI_TEAM_MAILBOX_ADDRESS": di_team_mailbox_address, + "DI_SYSTEM_MAILBOX_ADDRESS": di_system_mailbox_address, + "DI_SYSTEM_MAILBOX_PASSWORD": di_system_mailbox_password, + } + # Act + response = send_email( + email_address=RECIPIENT_EMAIL_ADDRESS, + html_content=EMAIL_BODY, + subject=EMAIL_SUBJECT, + correlation_id=CORRELATION_ID, + ) + # Assert + assert response is None + mock_get_secret.assert_called_once_with(secret_name) + mock_smtp.assert_called_once_with(host="smtp.office365.com", port=587, timeout=15) + mock_smtp.return_value.ehlo.assert_called_once() + mock_smtp.return_value.starttls.assert_called_once() + mock_smtp.return_value.login.assert_called_once_with(di_system_mailbox_address, di_system_mailbox_password) + mock_smtp.return_value.sendmail.assert_called_once_with( + from_addr=di_system_mailbox_address, + to_addrs=[RECIPIENT_EMAIL_ADDRESS], + msg=mock_mime_multipart.return_value.as_string.return_value, + ) + mock_smtp.return_value.quit.assert_called_once() + # Clean up + del environ["AWS_ACCOUNT_NAME"] + del environ["EMAIL_SECRET_NAME"] + + +@patch(f"{FILE_PATH}.MIMEMultipart") +@patch(f"{FILE_PATH}.SMTP") +@patch(f"{FILE_PATH}.get_secret") +def test_send_email_nonprod(mock_get_secret: MagicMock, mock_smtp: MagicMock, mock_mime_multipart: MagicMock) -> None: + # Arrange + environ["AWS_ACCOUNT_NAME"] = "nonprod" + # Act + response = send_email( + email_address=RECIPIENT_EMAIL_ADDRESS, + html_content=EMAIL_BODY, + subject=EMAIL_SUBJECT, + correlation_id=CORRELATION_ID, + ) + # Assert + assert response is None + mock_get_secret.assert_not_called() + mock_smtp.assert_not_called() + mock_mime_multipart.assert_not_called() + # Clean up + del environ["AWS_ACCOUNT_NAME"] + + +@patch(f"{FILE_PATH}.MIMEMultipart") +@patch(f"{FILE_PATH}.SMTP") +@patch(f"{FILE_PATH}.get_secret") +def test_send_email_exception( + mock_get_secret: MagicMock, + mock_smtp: MagicMock, + mock_mime_multipart: MagicMock, +) -> None: + # Arrange + environ["AWS_ACCOUNT_NAME"] = "test" + environ["EMAIL_SECRET_NAME"] = secret_name = "mock_secret_name" + di_team_mailbox_address = "di_team_mailbox_address" + di_system_mailbox_address = "di_system_mailbox_address" + di_system_mailbox_password = "di_system_mailbox_password" + mock_get_secret.return_value = { + "DI_TEAM_MAILBOX_ADDRESS": di_team_mailbox_address, + "DI_SYSTEM_MAILBOX_ADDRESS": di_system_mailbox_address, + "DI_SYSTEM_MAILBOX_PASSWORD": di_system_mailbox_password, + } + mock_smtp.return_value.ehlo.side_effect = SMTPException() + # Act + with pytest.raises(SMTPException, match="An error occurred while sending the email"): + send_email( + email_address=RECIPIENT_EMAIL_ADDRESS, + html_content=EMAIL_BODY, + subject=EMAIL_SUBJECT, + correlation_id=CORRELATION_ID, + ) + # Assert + mock_get_secret.assert_called_once_with(secret_name) + mock_smtp.assert_called_once_with(host="smtp.office365.com", port=587, timeout=15) + mock_smtp.return_value.ehlo.assert_called_once() + mock_smtp.return_value.starttls.assert_not_called() + mock_smtp.return_value.login.assert_not_called() + mock_smtp.return_value.sendmail.assert_not_called() + mock_smtp.return_value.quit.assert_not_called() + # Clean up + del environ["AWS_ACCOUNT_NAME"] + del environ["EMAIL_SECRET_NAME"] diff --git a/application/orchestrator/__init__.py b/application/service_matcher/__init__.py similarity index 100% rename from application/orchestrator/__init__.py rename to application/service_matcher/__init__.py diff --git a/application/service_matcher/matching.py b/application/service_matcher/matching.py new file mode 100644 index 000000000..697e49560 --- /dev/null +++ b/application/service_matcher/matching.py @@ -0,0 +1,28 @@ +from aws_lambda_powertools.logging import Logger + +from common.dos import DoSService, get_matching_dos_services +from common.nhs import NHSEntity + +logger = Logger(child=True) + + +def get_matching_services(nhs_entity: NHSEntity) -> list[DoSService]: + """Gets the matching DoS services for the given nhs entity. + + Using the nhs entity attributed to this object, it finds the + matching DoS services from the db and filters the results. + + Args: + nhs_entity (NHSEntity): The nhs entity to match against. + + Returns: + list[DoSService]: The list of matching DoS services. + """ + # Check database for services with same first 5 digits of ODSCode + logger.debug(f"Getting matching DoS Services for odscode '{nhs_entity.odscode}'.") + matching_services = get_matching_dos_services(nhs_entity.odscode) + logger.info( + f"Found {len(matching_services)} services in DB with matching first 5 chars of ODSCode: {matching_services}", + ) + + return matching_services diff --git a/application/service_matcher/reporting.py b/application/service_matcher/reporting.py new file mode 100644 index 000000000..7143ec56c --- /dev/null +++ b/application/service_matcher/reporting.py @@ -0,0 +1,153 @@ +import json +from os import getenv + +from aws_lambda_powertools.logging.logger import Logger + +from common.commissioned_service_type import CommissionedServiceType +from common.constants import DOS_ACTIVE_STATUS_ID, PHARMACY_SERVICE_TYPE_ID +from common.dos import DoSService +from common.nhs import NHSEntity + +logger = Logger(child=True) + +HIDDEN_OR_CLOSED_REPORT_ID = "HIDDEN_OR_CLOSED" +UNMATCHED_PHARMACY_REPORT_ID = "UNMATCHED_PHARMACY" +INVALID_OPEN_TIMES_REPORT_ID = "INVALID_OPEN_TIMES" +MISSING_SERVICE_TYPE_REPORT_ID = "MISSING_SERVICE_TYPE" + + +def log_closed_or_hidden_services( + nhs_entity: NHSEntity, + matching_services: list[DoSService], +) -> None: + """Log closed or hidden NHS UK services. + + Args: + nhs_entity (NHSEntity): The NHS entity to report + matching_services (List[DoSService]): The list of DoS matching services + """ + for dos_service in matching_services: + logger.warning( + "NHS Service marked as closed or hidden, no change events will be produced from this event", + report_key=HIDDEN_OR_CLOSED_REPORT_ID, + dos_service_uid=dos_service.uid, + nhsuk_odscode=nhs_entity.odscode, + dos_service_name=dos_service.name, + nhsuk_service_status=nhs_entity.org_status, + nhsuk_service_type=nhs_entity.org_type, + nhsuk_sector=nhs_entity.org_sub_type, + dos_service_status=dos_service.status_name, + dos_service_type=dos_service.service_type_name, + dos_region=dos_service.get_region(), + nhsuk_parent_organisation_name=nhs_entity.parent_org_name, + dos_service_typeid=dos_service.typeid, + ) + + +def log_unmatched_nhsuk_service(nhs_entity: NHSEntity) -> None: + """Log unmatched NHS Services. + + Args: + nhs_entity (NHSEntity): NHS entity to log. + """ + logger.warning( + f"No matching DOS services found that fit all criteria for ODSCode '{nhs_entity.odscode}'", + report_key=UNMATCHED_PHARMACY_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_name=nhs_entity.org_name, + nhsuk_organisation_typeid=nhs_entity.org_type_id, + nhsuk_organisation_status=nhs_entity.org_status, + nhsuk_organisation_subtype=nhs_entity.org_sub_type, + nhsuk_address1=nhs_entity.entity_data.get("Address1", ""), + nhsuk_address2=nhs_entity.entity_data.get("Address2", ""), + nhsuk_address3=nhs_entity.entity_data.get("Address3", ""), + nhsuk_city=nhs_entity.entity_data.get("City", ""), + nhsuk_county=nhs_entity.entity_data.get("County", ""), + nhsuk_postcode=nhs_entity.postcode, + nhsuk_parent_organisation_name=nhs_entity.parent_org_name, + ) + + +def log_invalid_open_times( + nhs_entity: NHSEntity, + matching_services: list[DoSService], +) -> None: + """Report invalid open times for nhs entity. + + Args: + nhs_entity (NHSEntity): The NHS entity to report + matching_services (List[DoSService]): The list of DoS matching services + """ + error_msg = f"NHS Entity '{nhs_entity.odscode}' has a misformatted or illogical set of opening times." + logger.warning( + error_msg, + report_key=INVALID_OPEN_TIMES_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_name=nhs_entity.org_name, + nhsuk_open_times_payload=json.dumps(nhs_entity.entity_data["OpeningTimes"]), + dos_service_type_name=", ".join(str(service.service_type_name) for service in matching_services), + dos_services=", ".join(str(service.uid) for service in matching_services), + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="InvalidOpenTimes", + ) + + +def log_missing_dos_service_for_a_given_type( + nhs_entity: NHSEntity, + matching_services: list[DoSService], + missing_type: CommissionedServiceType, + reason: str, +) -> None: + """Reports when a Change Event has a Service Code defined and there isn't a corresponding DoS service. + + Args: + nhs_entity (NHSEntity): The NHS entity to report + matching_services (list[DoSService]): The DoS services to report + missing_type (CommissionedServiceType): The subtype being reported as missing descriptors + reason (str): The reason for the report + """ + active_pharmacy_service = None + for service in matching_services: + if service.statusid == DOS_ACTIVE_STATUS_ID and service.typeid == PHARMACY_SERVICE_TYPE_ID: + active_pharmacy_service = service + + if active_pharmacy_service is None: + return + + log_missing_dos_service( + nhs_entity=nhs_entity, + dos_service=active_pharmacy_service, + missing_type=missing_type, + reason=reason, + ) + + +def log_missing_dos_service( + nhs_entity: NHSEntity, + dos_service: DoSService, + missing_type: CommissionedServiceType, + reason: str, +) -> None: + """Reports when a Change Event has a Service Code defined and there isn't a corresponding DoS service. + + Args: + nhs_entity (NHSEntity): The NHS entity to report + dos_service (DoSService): The DoS service to report + missing_type (CommissionedServiceType): The subtype being reported as missing descriptors + reason (str): The reason for the report + """ + logger.warning( + "Missing DoS service for a certain type associated with a NHS UK Service Code", + report_key=MISSING_SERVICE_TYPE_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_name=nhs_entity.org_name, + nhsuk_organisation_typeid=nhs_entity.org_type_id, + nhsuk_organisation_status=nhs_entity.org_status, + nhsuk_organisation_subtype=nhs_entity.org_sub_type, + dos_missing_service_type=missing_type.TYPE_NAME, + dos_service_address=dos_service.address, + dos_service_postcode=dos_service.postcode, + dos_region=dos_service.get_region(), + reason=reason, + nhsuk_parent_organisation_name=nhs_entity.parent_org_name, + ) diff --git a/application/service_matcher/requirements.txt b/application/service_matcher/requirements.txt new file mode 100644 index 000000000..6c5b01dad --- /dev/null +++ b/application/service_matcher/requirements.txt @@ -0,0 +1,3 @@ +aws-lambda-powertools[tracer] ~= 2.43.0 +psycopg[binary] +pytz diff --git a/application/service_matcher/review_matches.py b/application/service_matcher/review_matches.py new file mode 100644 index 000000000..a487fda49 --- /dev/null +++ b/application/service_matcher/review_matches.py @@ -0,0 +1,146 @@ +from aws_lambda_powertools.logging import Logger + +from .reporting import ( + log_closed_or_hidden_services, + log_invalid_open_times, + log_missing_dos_service, + log_missing_dos_service_for_a_given_type, + log_unmatched_nhsuk_service, +) +from common.commissioned_service_type import BLOOD_PRESSURE, CONTRACEPTION, PALLIATIVE_CARE, CommissionedServiceType +from common.constants import DOS_ACTIVE_STATUS_ID, MAIN_PHARMACY_ODSCODE_LENGTH +from common.dos import DoSService +from common.nhs import NHSEntity + +logger = Logger(child=True) + + +def review_matches(matching_services: list[DoSService], nhs_entity: NHSEntity) -> list[DoSService] | None: + """Review and validate the matches and log any issues. + + Args: + matching_services (list[DoSService]): The list of matching DoS services. + nhs_entity (NHSEntity): The NHS entity to report + + Returns: + list[DoSService]: The list of matching DoS services. + """ + if not matching_services or not next( + (True for service in matching_services if service.statusid == DOS_ACTIVE_STATUS_ID), + False, + ): + log_unmatched_nhsuk_service(nhs_entity) + return None + + remove_service_if_not_on_change_event( + matching_services=matching_services, + nhs_entity=nhs_entity, + nhs_uk_key="blood_pressure", + service_type=BLOOD_PRESSURE, + ) + + remove_service_if_not_on_change_event( + matching_services=matching_services, + nhs_entity=nhs_entity, + nhs_uk_key="contraception", + service_type=CONTRACEPTION, + ) + + logger.info("Matched DoS Services after services filtered", matched=matching_services) + + if nhs_entity.is_status_hidden_or_closed(): + log_closed_or_hidden_services(nhs_entity, matching_services) + return None + + if not nhs_entity.all_times_valid(): + log_invalid_open_times(nhs_entity, matching_services) + + # Check for correct pharmacy profiling + dos_matching_service_types = [service.typeid for service in matching_services] + logger.debug(f"Matched service types: {dos_matching_service_types}", matched=matching_services) + + check_for_missing_dos_services(nhs_entity, matching_services, BLOOD_PRESSURE) + check_for_missing_dos_services(nhs_entity, matching_services, CONTRACEPTION) + check_for_missing_palliative_care_service(nhs_entity, matching_services) + return matching_services + + +def remove_service_if_not_on_change_event( + matching_services: list[DoSService], + nhs_entity: NHSEntity, + nhs_uk_key: str, + service_type: CommissionedServiceType, +) -> list[DoSService]: + """Removes a service from the matching services list if it is not on the change event. + + Args: + matching_services (list[DoSService]): The list of matching services + nhs_entity (NHSEntity): The nhs entity to check for the service + nhs_uk_key (str): The key to check for the service on the nhs entity + service_type (CommissionedServiceType): Various constants for the service type + + Returns: + list[DoSService]: The list of matching services with the service removed if it is not on the change event + """ + if remove_matched_services := [ + service + for service in matching_services + if service.statusid != DOS_ACTIVE_STATUS_ID + and not getattr(nhs_entity, nhs_uk_key) + and service.typeid == service_type.DOS_TYPE_ID + ]: + for service in remove_matched_services: + matching_services.remove(service) + logger.info( + f"Removing matched {service_type.TYPE_NAME.lower()} services", + remove_matched_services=remove_matched_services, + matched=matching_services, + ) + return matching_services + + +def check_for_missing_dos_services( + nhs_entity: NHSEntity, + matching_services: list[DoSService], + service_type: CommissionedServiceType, +) -> None: + """Logs when a Change Event has a Service Code defined and there isn't a corresponding DoS service. + + Args: + nhs_entity (NHSEntity): The nhs entity to check for the service + matching_services (List[DosService]): The matching DoS service to check for the + service_type (CommissionedServiceType): Various constants for the service type + """ + if nhs_entity.check_for_service(service_type.NHS_UK_SERVICE_CODE) and not next( + (True for service in matching_services if service.typeid == service_type.DOS_TYPE_ID), + False, + ): + log_missing_dos_service_for_a_given_type( + nhs_entity=nhs_entity, + matching_services=matching_services, + missing_type=service_type, + reason=f"No '{service_type.TYPE_NAME}' type service profile", + ) + + +def check_for_missing_palliative_care_service(nhs_entity: NHSEntity, matching_services: list[DoSService]) -> None: + """Logs when a Change Event has Palliative Care defined and there isn't a corresponding DoS service. + + Args: + nhs_entity (NHSEntity): The nhs entity to check for the service + matching_services (List[DosService]): The matching DoS service to check for the + """ + if nhs_entity.palliative_care and not next( + ( + True + for service in matching_services + if service.typeid == PALLIATIVE_CARE.DOS_TYPE_ID and len(service.odscode) == MAIN_PHARMACY_ODSCODE_LENGTH + ), + False, + ): + log_missing_dos_service( + nhs_entity=nhs_entity, + dos_service=matching_services[0], + missing_type=PALLIATIVE_CARE, + reason="No Active Pharmacy with 5 Character ODSCode", + ) diff --git a/application/service_matcher/service_matcher.py b/application/service_matcher/service_matcher.py new file mode 100644 index 000000000..03127f505 --- /dev/null +++ b/application/service_matcher/service_matcher.py @@ -0,0 +1,125 @@ +from hashlib import sha256 +from json import dumps +from os import environ, getenv +from typing import Any + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.tracing import Tracer +from aws_lambda_powertools.utilities.data_classes import SQSEvent, event_source +from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext +from boto3 import client + +from .matching import get_matching_services +from .review_matches import review_matches +from common.middlewares import unhandled_exception_logging +from common.nhs import NHSEntity +from common.types import HoldingQueueChangeEventItem, UpdateRequest +from common.utilities import extract_body + +logger = Logger() +tracer = Tracer() +sqs = client("sqs") + + +@unhandled_exception_logging() +@tracer.capture_lambda_handler() +@logger.inject_lambda_context(clear_state=True) +@event_source(data_class=SQSEvent) +def lambda_handler(event: SQSEvent, context: LambdaContext) -> None: # noqa: ARG001 + """Entrypoint handler for the service_matcher lambda. + + Args: + event (SQSEvent): Lambda function invocation event (list of 1 SQS Message) + Change Event has been validate by the ingest change event lambda + context (LambdaContext): Lambda function context object + + Event: The event payload should contain a NHS Entity (Service) + """ + record = next(event.records) + holding_queue_change_event_item: HoldingQueueChangeEventItem = extract_body(record.body) + logger.set_correlation_id(holding_queue_change_event_item["correlation_id"]) + change_event = holding_queue_change_event_item["change_event"] + + nhs_entity = NHSEntity(change_event) + logger.append_keys(ods_code=nhs_entity.odscode, org_type=nhs_entity.org_type, org_sub_type=nhs_entity.org_sub_type) + logger.info("Created NHS Entity for processing", nhs_entity=nhs_entity) + + matching_services = get_matching_services(nhs_entity) + matching_services = review_matches(matching_services, nhs_entity) + if matching_services is None: + return + update_requests: list[UpdateRequest] = [ + {"change_event": change_event, "service_id": str(dos_service.id)} for dos_service in matching_services + ] + + send_update_requests( + update_requests=update_requests, + message_received=holding_queue_change_event_item["message_received"], + record_id=holding_queue_change_event_item["dynamo_record_id"], + sequence_number=holding_queue_change_event_item["sequence_number"], + ) + + +def divide_chunks(to_chunk: list, chunk_size: int) -> Any: # noqa: ANN401 + """Yield successive n-sized chunks from l.""" + # looping till length l + for i in range(0, len(to_chunk), chunk_size): + yield to_chunk[i : i + chunk_size] + + +def send_update_requests( + update_requests: list[dict[str, Any]], + message_received: int, + record_id: str, + sequence_number: int, +) -> None: + """Sends update request payload off to next part of workflow.""" + messages = [] + for update_request in update_requests: + service_id = update_request.get("service_id") + update_request_json = dumps(update_request) + encoded = update_request_json.encode() + hashed_payload = sha256(encoded).hexdigest() + message_deduplication_id = f"{service_id}-{hashed_payload}" + message_group_id = str(service_id) + entry_id = f"{service_id}-{sequence_number}" + logger.debug( + "Update request to send", + update_request=update_request, + entry_id=entry_id, + hashed_payload=f"{len(hashed_payload)} - {hashed_payload}", + message_deduplication_id=message_deduplication_id, + message_group_id=message_group_id, + sequence_number=str(sequence_number), + ) + messages.append( + { + "Id": entry_id, + "MessageBody": update_request_json, + "MessageDeduplicationId": message_deduplication_id, + "MessageGroupId": message_group_id, + "MessageAttributes": { + "correlation_id": {"DataType": "String", "StringValue": logger.get_correlation_id()}, + "message_received": {"DataType": "Number", "StringValue": str(message_received)}, + "dynamo_record_id": {"DataType": "String", "StringValue": record_id}, + "ods_code": { + "DataType": "String", + "StringValue": update_request.get("change_event").get("ODSCode"), + }, + "message_deduplication_id": {"DataType": "String", "StringValue": message_deduplication_id}, + "message_group_id": {"DataType": "String", "StringValue": message_group_id}, + }, + }, + ) + chunks = list(divide_chunks(messages, 10)) + for i, chunk in enumerate(chunks): + # TODO: Handle errors? + logger.debug(f"Sending off message chunk {i+1}/{len(chunks)}") + response = sqs.send_message_batch(QueueUrl=environ["UPDATE_REQUEST_QUEUE_URL"], Entries=chunk) + logger.debug("Sent off message chunk", response=response) + logger.warning( + "Sent Off Update Request", + service_id=service_id, + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="UpdateRequestSent", + ) diff --git a/application/service_matcher/tests/__init__.py b/application/service_matcher/tests/__init__.py new file mode 100644 index 000000000..93537baa3 --- /dev/null +++ b/application/service_matcher/tests/__init__.py @@ -0,0 +1,7 @@ +from importlib import import_module +from sys import modules + +modules["opening_times"] = import_module("common.opening_times") +modules["nhs"] = import_module("common.nhs") +modules["dos"] = import_module("common.dos") +modules["errors"] = import_module("common.errors") diff --git a/application/service_matcher/tests/test_matching.py b/application/service_matcher/tests/test_matching.py new file mode 100644 index 000000000..134cc364a --- /dev/null +++ b/application/service_matcher/tests/test_matching.py @@ -0,0 +1,38 @@ +from unittest.mock import MagicMock, patch + +from application.conftest import dummy_dos_service +from application.service_matcher.matching import get_matching_services +from common.nhs import NHSEntity + +FILE_PATH = "application.service_matcher.matching" + + +@patch(f"{FILE_PATH}.get_matching_dos_services") +def test_get_matching_services( + mock_get_matching_dos_services: MagicMock, + change_event: dict[str, str], +) -> None: + # Arrange + nhs_entity = NHSEntity(change_event) + service = dummy_dos_service() + service.typeid = 13 + service.statusid = 1 + mock_get_matching_dos_services.return_value = [service] + # Act + matching_services = get_matching_services(nhs_entity) + # Assert + assert matching_services == [service] + + +@patch(f"{FILE_PATH}.get_matching_dos_services") +def test_get_unmatching_services( + mock_get_matching_dos_services: MagicMock, + change_event: dict[str, str], +) -> None: + # Arrange + nhs_entity = NHSEntity(change_event) + mock_get_matching_dos_services.return_value = [] + # Act + response = get_matching_services(nhs_entity) + # Assert + assert response == [] diff --git a/application/service_matcher/tests/test_reporting.py b/application/service_matcher/tests/test_reporting.py new file mode 100644 index 000000000..80271efcb --- /dev/null +++ b/application/service_matcher/tests/test_reporting.py @@ -0,0 +1,184 @@ +import json +from unittest.mock import MagicMock, patch + +from aws_lambda_powertools.logging import Logger + +from application.conftest import dummy_dos_service +from application.service_matcher.reporting import ( + HIDDEN_OR_CLOSED_REPORT_ID, + INVALID_OPEN_TIMES_REPORT_ID, + MISSING_SERVICE_TYPE_REPORT_ID, + UNMATCHED_PHARMACY_REPORT_ID, + log_closed_or_hidden_services, + log_invalid_open_times, + log_missing_dos_service_for_a_given_type, + log_unmatched_nhsuk_service, +) +from common.commissioned_service_type import BLOOD_PRESSURE +from common.constants import PHARMACY_SERVICE_TYPE_ID +from common.nhs import NHSEntity + + +@patch.object(Logger, "warning") +def test_log_closed_or_hidden_services(mock_logger: MagicMock, change_event: dict[str, str]) -> None: + # Arrange + nhs_entity = NHSEntity(change_event) + dos_service = dummy_dos_service() + matching_services = [dos_service] + # Act + log_closed_or_hidden_services(nhs_entity, matching_services) + # Assert + assert ( + HIDDEN_OR_CLOSED_REPORT_ID == "HIDDEN_OR_CLOSED" + ), f"Report ID should be HIDDEN_OR_CLOSED but was {HIDDEN_OR_CLOSED_REPORT_ID}" + mock_logger.assert_called_with( + "NHS Service marked as closed or hidden, no change events will be produced from this event", + report_key=HIDDEN_OR_CLOSED_REPORT_ID, + dos_service_uid=dos_service.uid, + nhsuk_odscode=nhs_entity.odscode, + dos_service_name=dos_service.name, + nhsuk_service_status=nhs_entity.org_status, + nhsuk_service_type=nhs_entity.org_type, + nhsuk_sector=nhs_entity.org_sub_type, + dos_service_status=dos_service.status_name, + dos_service_type=dos_service.service_type_name, + dos_region=dos_service.get_region(), + nhsuk_parent_organisation_name=nhs_entity.parent_org_name, + dos_service_typeid=dos_service.typeid, + ) + + +@patch.object(Logger, "warning") +def test_log_unmatched_nhsuk_service(mock_logger: MagicMock) -> None: + # Arrange + nhs_entity = NHSEntity( + { + "ODSCode": "SLC4X", + "OrganisationName": "OrganisationName", + "OrganisationTypeId": "PHA", + "OrganisationStatus": "OrganisationStatus", + "OrganisationSubType": "OrganisationSubType", + "Address2": "address2", + "Address3": "address3", + "City": "city", + "County": "country", + "Postcode": "MK2 4AX", + }, + ) + # Act + log_unmatched_nhsuk_service(nhs_entity) + # Assert + assert ( + UNMATCHED_PHARMACY_REPORT_ID == "UNMATCHED_PHARMACY" + ), f"Log ID should be UNMATCHED_PHARMACY but was {UNMATCHED_PHARMACY_REPORT_ID}" + mock_logger.assert_called_with( + f"No matching DOS services found that fit all criteria for ODSCode '{nhs_entity.odscode}'", + report_key=UNMATCHED_PHARMACY_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_name=nhs_entity.org_name, + nhsuk_organisation_typeid=nhs_entity.org_type_id, + nhsuk_organisation_status=nhs_entity.org_status, + nhsuk_organisation_subtype=nhs_entity.org_sub_type, + nhsuk_address1=nhs_entity.entity_data.get("Address1", ""), + nhsuk_address2=nhs_entity.entity_data.get("Address2", ""), + nhsuk_address3=nhs_entity.entity_data.get("Address3", ""), + nhsuk_city=nhs_entity.entity_data.get("City", ""), + nhsuk_county=nhs_entity.entity_data.get("County", ""), + nhsuk_postcode=nhs_entity.postcode, + nhsuk_parent_organisation_name=nhs_entity.parent_org_name, + ) + + +@patch.object(Logger, "warning") +def test_log_invalid_open_times(mock_logger: MagicMock) -> None: + # Arrange + opening_times = [ + { + "Weekday": "Monday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": True, + }, + { + "Weekday": "Monday", + "OpeningTime": "12:00", + "ClosingTime": "17:30", + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": True, + }, + ] + nhs_entity = NHSEntity({"OpeningTimes": opening_times}) + nhs_entity.odscode = "SLC4X" + nhs_entity.org_name = "OrganisationName" + + dos_services = [dummy_dos_service() for _ in range(3)] + # Act + log_invalid_open_times(nhs_entity, dos_services) + # Assert + mock_logger.assert_called_with( + f"NHS Entity '{nhs_entity.odscode}' has a misformatted or illogical set of opening times.", + report_key=INVALID_OPEN_TIMES_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_name=nhs_entity.org_name, + nhsuk_open_times_payload=json.dumps(nhs_entity.entity_data["OpeningTimes"]), + dos_service_type_name=", ".join(str(service.service_type_name) for service in dos_services), + dos_services=", ".join(str(service.uid) for service in dos_services), + environment="local", + cloudwatch_metric_filter_matching_attribute="InvalidOpenTimes", + ) + + +@patch.object(Logger, "warning") +def test_log_missing_dos_service_for_a_given_type(mock_logger: MagicMock) -> None: + dos_service = dummy_dos_service() + dos_service.typeid = PHARMACY_SERVICE_TYPE_ID + dos_service.statusid = 1 + reason = "reason 123" + nhs_entity = NHSEntity( + {"Address1": "address1", "Address2": "address2", "Address3": "address3", "City": "city", "County": "county"}, + ) + nhs_entity.odscode = "SLC4X" + nhs_entity.org_name = "OrganisationName" + nhs_entity.org_type_id = "PHA" + nhs_entity.org_status = "OrganisationStatus" + nhs_entity.org_sub_type = "OrganisationSubType" + nhs_entity.postcode = "MK2 XXX" + log_missing_dos_service_for_a_given_type(nhs_entity, [dos_service], BLOOD_PRESSURE, reason) + assert MISSING_SERVICE_TYPE_REPORT_ID == "MISSING_SERVICE_TYPE" + mock_logger.assert_called_with( + "Missing DoS service for a certain type associated with a NHS UK Service Code", + report_key=MISSING_SERVICE_TYPE_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_name=nhs_entity.org_name, + nhsuk_organisation_typeid=nhs_entity.org_type_id, + nhsuk_organisation_status=nhs_entity.org_status, + nhsuk_organisation_subtype=nhs_entity.org_sub_type, + dos_missing_service_type=BLOOD_PRESSURE.TYPE_NAME, + dos_service_address=dos_service.address, + dos_service_postcode=dos_service.postcode, + dos_region=dos_service.get_region(), + reason=reason, + nhsuk_parent_organisation_name=nhs_entity.parent_org_name, + ) + + +@patch.object(Logger, "warning") +def test_log_missing_dos_service_for_a_given_type__no_active_dos_services(mock_logger: MagicMock) -> None: + dos_service = dummy_dos_service() + dos_service.statusid = 3 + reason = "reason 123" + nhs_entity = NHSEntity( + {"Address1": "address1", "Address2": "address2", "Address3": "address3", "City": "city", "County": "county"}, + ) + nhs_entity.odscode = "SLC4X" + nhs_entity.org_name = "OrganisationName" + nhs_entity.org_type_id = "PHA" + nhs_entity.org_status = "OrganisationStatus" + nhs_entity.org_sub_type = "OrganisationSubType" + nhs_entity.postcode = "MK2 XXX" + log_missing_dos_service_for_a_given_type(nhs_entity, [dos_service], BLOOD_PRESSURE, reason) + assert MISSING_SERVICE_TYPE_REPORT_ID == "MISSING_SERVICE_TYPE" + mock_logger.assert_not_called() diff --git a/application/service_matcher/tests/test_review_matches.py b/application/service_matcher/tests/test_review_matches.py new file mode 100644 index 000000000..c86587fa6 --- /dev/null +++ b/application/service_matcher/tests/test_review_matches.py @@ -0,0 +1,181 @@ +from unittest.mock import MagicMock, patch + +from .test_service_matcher import PHARMACY_STANDARD_EVENT +from application.common.nhs import NHSEntity +from application.conftest import dummy_dos_service +from application.service_matcher.review_matches import ( + check_for_missing_dos_services, + check_for_missing_palliative_care_service, + remove_service_if_not_on_change_event, + review_matches, +) +from common.commissioned_service_type import BLOOD_PRESSURE, PALLIATIVE_CARE + +FILE_PATH = "application.service_matcher.review_matches" + + +def test_review_changes() -> None: + # Arrange + service = dummy_dos_service() + service.typeid = 13 + service.statusid = 1 + matching_services = [service] + nhs_entity = NHSEntity(PHARMACY_STANDARD_EVENT) + # Act + response = review_matches(matching_services, nhs_entity) + # Assert + assert response == matching_services + + +@patch(f"{FILE_PATH}.log_closed_or_hidden_services") +def test_review_matches__hidden_or_closed(mock_log_closed_or_hidden_services: MagicMock) -> None: + # Arrange + nhs_entity = NHSEntity(PHARMACY_STANDARD_EVENT) + nhs_entity.org_status = "Closed" + nhs_entity.org_type = "Pharmacy" + nhs_entity.org_sub_type = "Pharmacy" + service = dummy_dos_service() + service.statusid = 1 + matching_services = [service] + # Act + response = review_matches(matching_services, nhs_entity) + # Assert + assert response is None + mock_log_closed_or_hidden_services.assert_called_once_with(nhs_entity, matching_services) + + +@patch(f"{FILE_PATH}.log_invalid_open_times") +def test_review_matches__invalid_opening_times(mock_log_invalid_open_times: MagicMock) -> None: + # Arrange + change_event = PHARMACY_STANDARD_EVENT.copy() + change_event["OpeningTimes"] = [ + { + "Weekday": "Monday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": True, + }, + { + "Weekday": "Monday", + "OpeningTime": "12:00", + "ClosingTime": "17:30", + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": True, + }, + ] + nhs_entity = NHSEntity(change_event) + service = dummy_dos_service() + service.statusid = 1 + matching_services = [service] + # Act + response = review_matches(matching_services, nhs_entity) + # Assert + assert response == matching_services + mock_log_invalid_open_times.assert_called_once_with(nhs_entity, matching_services) + + +@patch(f"{FILE_PATH}.log_missing_dos_service_for_a_given_type") +def test_check_for_missing_dos_services__missing(mock_log_missing_dos_service_for_a_given_type: MagicMock) -> None: + # Arrange + entity = MagicMock() + entity.check_for_service.return_value = True + service = dummy_dos_service() + service.typeid = 13 + service.statusid = 1 + matching_dos_services = [service] + # Act + check_for_missing_dos_services(entity, matching_dos_services, BLOOD_PRESSURE) + # Assert + entity.check_for_service.assert_called_once_with(BLOOD_PRESSURE.NHS_UK_SERVICE_CODE) + mock_log_missing_dos_service_for_a_given_type.assert_called_once_with( + nhs_entity=entity, + matching_services=matching_dos_services, + missing_type=BLOOD_PRESSURE, + reason="No 'Blood Pressure' type service profile", + ) + + +@patch(f"{FILE_PATH}.log_missing_dos_service_for_a_given_type") +def test_check_for_missing_dos_services__not_missing( + mock_log_missing_dos_service_for_a_given_type: MagicMock, change_event: dict[str, str] +) -> None: + # Arrange + entity = MagicMock() + entity.check_for_service.return_value = True + service = dummy_dos_service() + service.typeid = 13 + service.statusid = 1 + service_two = dummy_dos_service() + service_two.typeid = BLOOD_PRESSURE.DOS_TYPE_ID + service_two.statusid = 1 + matching_dos_services = [service, service_two] + + # Act + check_for_missing_dos_services(entity, matching_dos_services, BLOOD_PRESSURE) + + # Assert + entity.check_for_service.assert_called_once_with(BLOOD_PRESSURE.NHS_UK_SERVICE_CODE) + mock_log_missing_dos_service_for_a_given_type.assert_not_called() + + +@patch(f"{FILE_PATH}.log_missing_dos_service_for_a_given_type") +def test_check_for_missing_dos_services__not_on_nhs_entity( + mock_log_missing_dos_service_for_a_given_type: MagicMock, change_event: dict[str, str] +) -> None: + # Arrange + entity = MagicMock() + entity.check_for_service.return_value = False + service = dummy_dos_service() + service.typeid = 13 + service.statusid = 1 + service_two = dummy_dos_service() + service_two.typeid = BLOOD_PRESSURE.DOS_TYPE_ID + service_two.statusid = 1 + matching_dos_services = [service, service_two] + + # Act + check_for_missing_dos_services(entity, matching_dos_services, BLOOD_PRESSURE) + + # Assert + entity.check_for_service.assert_called_once_with(BLOOD_PRESSURE.NHS_UK_SERVICE_CODE) + mock_log_missing_dos_service_for_a_given_type.assert_not_called() + + +def test_remove_service_if_not_on_change_event() -> None: + # Arrange + service = dummy_dos_service() + service.typeid = 13 + service.statusid = 1 + service2 = dummy_dos_service() + service2.typeid = 148 + service2.statusid = 2 + matching_services = [service, service2] + nhs_entity = NHSEntity(PHARMACY_STANDARD_EVENT) + nhs_entity.blood_pressure = False + # Act + response = remove_service_if_not_on_change_event(matching_services, nhs_entity, "blood_pressure", BLOOD_PRESSURE) + # Assert + assert response == [service] + + +@patch(f"{FILE_PATH}.log_missing_dos_service") +def test_check_for_missing_palliative_care_service(mock_log_missing_dos_service: MagicMock) -> None: + # Arrange + service = dummy_dos_service() + service.typeid = 131 + service.statusid = 1 + matching_services = [service] + nhs_entity = NHSEntity(PHARMACY_STANDARD_EVENT) + nhs_entity.palliative_care = True + # Act + check_for_missing_palliative_care_service(nhs_entity, matching_services) + # Assert + mock_log_missing_dos_service.assert_called_once_with( + nhs_entity=nhs_entity, + dos_service=service, + missing_type=PALLIATIVE_CARE, + reason="No Active Pharmacy with 5 Character ODSCode", + ) diff --git a/application/service_matcher/tests/test_service_matcher.py b/application/service_matcher/tests/test_service_matcher.py new file mode 100644 index 000000000..8c88b3175 --- /dev/null +++ b/application/service_matcher/tests/test_service_matcher.py @@ -0,0 +1,206 @@ +import hashlib +from json import dumps +from os import environ +from unittest.mock import MagicMock, patch + +import pytest +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.utilities.typing import LambdaContext + +from application.common.types import HoldingQueueChangeEventItem +from application.conftest import PHARMACY_STANDARD_EVENT, dummy_dos_service +from application.service_matcher.service_matcher import lambda_handler, send_update_requests +from common.nhs import NHSEntity + +FILE_PATH = "application.service_matcher.service_matcher" + + +def _get_message_attributes( + correlation_id: str, + message_received: int, + record_id: str, + ods_code: str, + message_deduplication_id: str, + message_group_id: str, +) -> dict[str, str]: + return { + "correlation_id": {"DataType": "String", "StringValue": correlation_id}, + "message_received": {"DataType": "Number", "StringValue": str(message_received)}, + "dynamo_record_id": {"DataType": "String", "StringValue": record_id}, + "ods_code": {"DataType": "String", "StringValue": ods_code}, + "message_deduplication_id": {"DataType": "String", "StringValue": message_deduplication_id}, + "message_group_id": {"DataType": "String", "StringValue": message_group_id}, + } + + +@patch(f"{FILE_PATH}.review_matches") +@patch(f"{FILE_PATH}.get_matching_services") +@patch(f"{FILE_PATH}.send_update_requests") +@patch(f"{FILE_PATH}.NHSEntity") +@patch(f"{FILE_PATH}.extract_body") +def test_lambda_handler( + mock_extract_body: MagicMock, + mock_nhs_entity: MagicMock, + mock_send_update_requests: MagicMock, + mock_get_matching_services: MagicMock, + mock_review_matches: MagicMock, + change_event: dict[str, str], + lambda_context: LambdaContext, +) -> None: + # Arrange + mock_entity = NHSEntity(change_event) + sqs_event = SQS_EVENT.copy() + sqs_event["Records"][0]["body"] = dumps(HOLDING_QUEUE_CHANGE_EVENT_ITEM) + mock_extract_body.return_value = HOLDING_QUEUE_CHANGE_EVENT_ITEM + mock_nhs_entity.return_value = mock_entity + service = dummy_dos_service() + mock_get_matching_services.return_value = [service] + mock_review_matches.return_value = [service] + environ["ENV"] = "test" + # Act + response = lambda_handler(sqs_event, lambda_context) + # Assert + assert response is None, f"Response should be None but is {response}" + mock_extract_body.assert_called_once_with(sqs_event["Records"][0]["body"]) + mock_nhs_entity.assert_called_once_with(change_event) + mock_get_matching_services.assert_called_once_with(mock_entity) + mock_review_matches.assert_called_once_with([service], mock_entity) + mock_send_update_requests.assert_called_once_with( + update_requests=[{"change_event": change_event, "service_id": service.id}], + message_received=HOLDING_QUEUE_CHANGE_EVENT_ITEM["message_received"], + record_id=HOLDING_QUEUE_CHANGE_EVENT_ITEM["dynamo_record_id"], + sequence_number=HOLDING_QUEUE_CHANGE_EVENT_ITEM["sequence_number"], + ) + # Clean up + del environ["ENV"] + + +@patch(f"{FILE_PATH}.get_matching_services") +@patch(f"{FILE_PATH}.send_update_requests") +@patch(f"{FILE_PATH}.NHSEntity") +@patch(f"{FILE_PATH}.extract_body") +def test_lambda_handler_unmatched_service( + mock_extract_body: MagicMock, + mock_nhs_entity: MagicMock, + mock_send_update_requests: MagicMock, + mock_get_matching_services: MagicMock, + change_event: dict[str, str], + lambda_context: LambdaContext, +) -> None: + # Arrange + mock_entity = NHSEntity(change_event) + sqs_event = SQS_EVENT.copy() + sqs_event["Records"][0]["body"] = dumps(HOLDING_QUEUE_CHANGE_EVENT_ITEM) + mock_extract_body.return_value = HOLDING_QUEUE_CHANGE_EVENT_ITEM + mock_nhs_entity.return_value = mock_entity + mock_get_matching_services.return_value = [] + environ["ENV"] = "test" + # Act + response = lambda_handler(sqs_event, lambda_context) + # Assert + assert response is None, f"Response should be None but is {response}" + mock_extract_body.assert_called_once_with(sqs_event["Records"][0]["body"]) + mock_nhs_entity.assert_called_once_with(change_event) + mock_get_matching_services.assert_called_once_with(mock_entity) + mock_send_update_requests.assert_not_called() + # Clean up + del environ["ENV"] + + +def test_lambda_handler_should_throw_exception_if_event_records_len_not_eq_one(lambda_context: LambdaContext) -> None: + # Arrange + sqs_event = SQS_EVENT.copy() + sqs_event["Records"] = [] + environ["ENV"] = "test" + # Act / Assert + with pytest.raises(StopIteration): + lambda_handler(sqs_event, lambda_context) + # Clean up + del environ["ENV"] + + +@patch(f"{FILE_PATH}.sqs") +@patch.object(Logger, "get_correlation_id", return_value="1") +@patch.object(Logger, "warning") +def test_send_update_requests( + mock_logger: MagicMock, + get_correlation_id_mock: MagicMock, + mock_sqs: MagicMock, +) -> None: + # Arrange + q_name = "test-queue" + environ["UPDATE_REQUEST_QUEUE_URL"] = q_name + message_received = 1642501355616 + record_id = "someid" + sequence_number = 1 + odscode = "FXXX1" + update_requests = [{"service_id": "1", "change_event": {"ODSCode": odscode}}] + # Act + send_update_requests( + update_requests=update_requests, + message_received=message_received, + record_id=record_id, + sequence_number=sequence_number, + ) + # Assert + payload = dumps(update_requests[0]) + encoded = payload.encode() + hashed_payload = hashlib.sha256(encoded).hexdigest() + entry_details = { + "Id": "1-1", + "MessageBody": payload, + "MessageDeduplicationId": f"1-{hashed_payload}", + "MessageGroupId": "1", + "MessageAttributes": _get_message_attributes( + "1", + message_received, + record_id, + odscode, + f"1-{hashed_payload}", + "1", + ), + } + mock_sqs.send_message_batch.assert_called_with( + QueueUrl=q_name, + Entries=[entry_details], + ) + mock_logger.assert_called_with( + "Sent Off Update Request", + service_id="1", + environment="local", + cloudwatch_metric_filter_matching_attribute="UpdateRequestSent", + ) + # Clean up + del environ["UPDATE_REQUEST_QUEUE_URL"] + + +HOLDING_QUEUE_CHANGE_EVENT_ITEM = HoldingQueueChangeEventItem( + change_event=PHARMACY_STANDARD_EVENT.copy(), + message_received=1234567890, + sequence_number=1, + dynamo_record_id="123", + correlation_id="correlation_id", +) +SQS_EVENT = { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": dumps(HOLDING_QUEUE_CHANGE_EVENT_ITEM), + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1642619743522", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185", + }, + "messageAttributes": { + "correlation-id": {"stringValue": "1", "dataType": "String"}, + "sequence-number": {"stringValue": "1", "dataType": "Number"}, + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2", + }, + ], +} diff --git a/application/test_db_checker_handler/__init__.py b/application/service_sync/__init__.py similarity index 100% rename from application/test_db_checker_handler/__init__.py rename to application/service_sync/__init__.py diff --git a/application/test_db_checker_handler/tests/__init__.py b/application/service_sync/data_processing/__init__.py similarity index 100% rename from application/test_db_checker_handler/tests/__init__.py rename to application/service_sync/data_processing/__init__.py diff --git a/application/service_sync/data_processing/changes_to_dos.py b/application/service_sync/data_processing/changes_to_dos.py new file mode 100644 index 000000000..d912c831b --- /dev/null +++ b/application/service_sync/data_processing/changes_to_dos.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass, field +from typing import Any + +from aws_lambda_powertools.logging import Logger + +from .service_histories import ServiceHistories +from common.dos import DoSService +from common.nhs import NHSEntity +from common.opening_times import SpecifiedOpeningTime + +logger = Logger(child=True) + + +@dataclass(init=True, repr=True) +class ChangesToDoS: + """Class to determine if an update needs to be made to the DoS db and if so, what the update should be.""" + + # Holding data classes for use within this class + dos_service: DoSService + nhs_entity: NHSEntity + service_histories: ServiceHistories + # Variable to know if fields need to be changed + demographic_changes: dict[str | None, Any] = field(default_factory=dict) + standard_opening_times_changes: dict[int | None, Any] = field(default_factory=dict) + specified_opening_times_changes: bool = False + palliative_care_changes: bool = False + blood_pressure_changes: bool = False + contraception_changes: bool = False + + # New value to be saved to the database + new_address: str | None = None + new_postcode: str | None = None + new_public_phone: str | None = None + new_specified_opening_times: list[SpecifiedOpeningTime] | None = None + new_website: str | None = None + + # Existing DoS data for use building service history + current_address: str | None = None + current_postcode: str | None = None + current_public_phone: str | None = None + current_specified_opening_times: list[SpecifiedOpeningTime] | None = None + current_website: str | None = None + + # Each day that has changed will have a current and new value in the format below + # new_day_opening_times e.g. new_monday_opening_times + # current_day_opening_times e.g. current_monday_opening_times + # The type of the value is a list of OpenPeriod objects diff --git a/application/service_sync/data_processing/check_for_change.py b/application/service_sync/data_processing/check_for_change.py new file mode 100644 index 000000000..0a08cb1c0 --- /dev/null +++ b/application/service_sync/data_processing/check_for_change.py @@ -0,0 +1,414 @@ +from typing import Any + +from aws_lambda_powertools.logging import Logger + +from ..reporting import log_blank_standard_opening_times +from .changes_to_dos import ChangesToDoS +from .comparison import ( + compare_blood_pressure, + compare_contraception, + compare_location, + compare_palliative_care, + compare_public_phone, + compare_specified_opening_times, + compare_standard_opening_times, + compare_website, +) +from .service_histories import ServiceHistories +from .service_histories_change import ServiceHistoriesChange +from .validation import validate_opening_times +from common.constants import ( + DI_LATITUDE_CHANGE_KEY, + DI_LONGITUDE_CHANGE_KEY, + DOS_ACTIVE_STATUS_ID, + DOS_ADDRESS_CHANGE_KEY, + DOS_BLOOD_PRESSURE_TYPE_ID, + DOS_CLOSED_STATUS_ID, + DOS_CONTRACEPTION_TYPE_ID, + DOS_EASTING_CHANGE_KEY, + DOS_NORTHING_CHANGE_KEY, + DOS_PALLIATIVE_CARE_SGSDID, + DOS_PALLIATIVE_CARE_TYPE_ID, + DOS_POSTAL_TOWN_CHANGE_KEY, + DOS_POSTCODE_CHANGE_KEY, + DOS_PUBLIC_PHONE_CHANGE_KEY, + DOS_SERVICE_HISTORY_ACTIVE_STATUS, + DOS_SERVICE_HISTORY_CLOSED_STATUS, + DOS_STANDARD_OPENING_TIMES_CHANGE_KEY_LIST, + DOS_STATUS_CHANGE_KEY, + DOS_WEBSITE_CHANGE_KEY, + MAIN_PHARMACY_ODSCODE_LENGTH, + NHS_UK_PALLIATIVE_CARE_SERVICE_CODE, +) +from common.dos import DoSService +from common.dos_location import DoSLocation +from common.nhs import NHSEntity, get_palliative_care_log_value, skip_if_key_is_none +from common.opening_times import DAY_IDS, WEEKDAYS + +logger = Logger(child=True) + + +def compare_nhs_uk_and_dos_data( + dos_service: DoSService, + nhs_entity: NHSEntity, + service_histories: ServiceHistories, +) -> ChangesToDoS: + """Compares the data of the dos_service and nhs_entity and returns a ChangesToDoS object. + + Args: + dos_service (DoSService): DoSService object to compare + nhs_entity (NHSEntity): NHS UK entity to compare + service_histories (ServiceHistories): ServiceHistories object with the service histories of the new changes + + Returns: + ChangesToDoS: ChangesToDoS class with all the flags if changes need to be made and the changes to make + """ + # Set up the holder class + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + + # Compare and validate website + changes_to_dos = check_website_for_change(changes_to_dos=changes_to_dos) + # Compare public phone + changes_to_dos = check_public_phone_for_change(changes_to_dos=changes_to_dos) + # Compare and validate address & postcode (easting, northing, town, latitude, longitude) + changes_to_dos = check_location_for_change(changes_to_dos=changes_to_dos) + # Compare and validate all opening_times + changes_to_dos = check_opening_times_for_changes(changes_to_dos=changes_to_dos) + # Compare palliative care + changes_to_dos = check_palliative_care_for_change(changes_to_dos=changes_to_dos) + # Compare blood pressure + changes_to_dos = check_blood_pressure_for_change(changes_to_dos=changes_to_dos) + # Compare contraception + return check_contraception_for_change(changes_to_dos=changes_to_dos) + + +def check_website_for_change(changes_to_dos: ChangesToDoS) -> ChangesToDoS: + """Compares and creates change for website if needed. + + Args: + changes_to_dos (ChangesToDoS): ChangesToDoS holder object + + Returns: + ChangesToDoS: ChangesToDoS holder object + """ + if compare_website(changes=changes_to_dos): + changes_to_dos = services_change( + changes_to_dos=changes_to_dos, + change_key=DOS_WEBSITE_CHANGE_KEY, + new_value=changes_to_dos.new_website, + previous_value=changes_to_dos.current_website, + service_table_field_name="web", + ) + return changes_to_dos + + +def check_public_phone_for_change(changes_to_dos: ChangesToDoS) -> ChangesToDoS: + """Compares and creates change for public phone if needed. + + Args: + changes_to_dos (ChangesToDoS): ChangesToDoS holder object + + Returns: + ChangesToDoS: ChangesToDoS holder object + """ + if compare_public_phone(changes=changes_to_dos): + changes_to_dos = services_change( + changes_to_dos=changes_to_dos, + change_key=DOS_PUBLIC_PHONE_CHANGE_KEY, + new_value=changes_to_dos.new_public_phone, + previous_value=changes_to_dos.current_public_phone, + service_table_field_name="publicphone", + ) + return changes_to_dos + + +def check_location_for_change(changes_to_dos: ChangesToDoS) -> ChangesToDoS: + """Compares and creates changes individually for location data items if needed. + + Location data covers the following fields: + - address + - postcode + - latitude + - longitude + - town + - easting + - northing. + + Args: + changes_to_dos (ChangesToDoS): ChangesToDoS holder object + + Returns: + ChangesToDoS: ChangesToDoS holder object + """ + address_change, postcode_change, dos_location = compare_location(changes=changes_to_dos) + if address_change: + changes_to_dos = services_change( + changes_to_dos=changes_to_dos, + change_key=DOS_ADDRESS_CHANGE_KEY, + new_value=changes_to_dos.new_address, + previous_value=changes_to_dos.current_address, + service_table_field_name="address", + ) + if postcode_change: + dos_location: DoSLocation # dos_location can not be none if postcode must be changed + changes_to_dos = services_change( + changes_to_dos=changes_to_dos, + change_key=DOS_POSTCODE_CHANGE_KEY, + new_value=changes_to_dos.new_postcode, + previous_value=changes_to_dos.current_postcode, + service_table_field_name="postcode", + ) + changes_to_dos = services_change( + changes_to_dos=changes_to_dos, + change_key=DOS_POSTAL_TOWN_CHANGE_KEY, + new_value=dos_location.postaltown, + previous_value=changes_to_dos.dos_service.town, + service_table_field_name="town", + ) + changes_to_dos = services_change( + changes_to_dos=changes_to_dos, + change_key=DOS_EASTING_CHANGE_KEY, + new_value=dos_location.easting, + previous_value=changes_to_dos.dos_service.easting, + service_table_field_name="easting", + ) + changes_to_dos = services_change( + changes_to_dos=changes_to_dos, + change_key=DOS_NORTHING_CHANGE_KEY, + new_value=dos_location.northing, + previous_value=changes_to_dos.dos_service.northing, + service_table_field_name="northing", + ) + changes_to_dos = services_change( + changes_to_dos=changes_to_dos, + change_key=DI_LATITUDE_CHANGE_KEY, + new_value=dos_location.latitude, + previous_value=changes_to_dos.dos_service.latitude, + service_table_field_name="latitude", + update_service_history=False, + ) + changes_to_dos = services_change( + changes_to_dos=changes_to_dos, + change_key=DI_LONGITUDE_CHANGE_KEY, + new_value=dos_location.longitude, + previous_value=changes_to_dos.dos_service.longitude, + service_table_field_name="longitude", + update_service_history=False, + ) + return changes_to_dos + + +def check_opening_times_for_changes(changes_to_dos: ChangesToDoS) -> ChangesToDoS: + """Compares and creates changes individually for all opening times if needed. + + Args: + changes_to_dos (ChangesToDoS): ChangesToDoS holder object + + Returns: + ChangesToDoS: ChangesToDoS holder object + """ + if validate_opening_times(dos_service=changes_to_dos.dos_service, nhs_entity=changes_to_dos.nhs_entity): + logger.debug("Opening times are valid") + if changes_to_dos.nhs_entity.standard_opening_times.fully_closed(): + log_blank_standard_opening_times( + nhs_entity=changes_to_dos.nhs_entity, + dos_service=changes_to_dos.dos_service, + ) + else: + logger.warning("Standard opening times are not blank") + # Compare standard opening times + for weekday, dos_weekday_key, day_id in zip( # noqa: B905 + WEEKDAYS, + DOS_STANDARD_OPENING_TIMES_CHANGE_KEY_LIST, + DAY_IDS, + ): + if compare_standard_opening_times(changes=changes_to_dos, weekday=weekday): + changes_to_dos.standard_opening_times_changes[day_id] = getattr( + changes_to_dos, + f"new_{weekday}_opening_times", + ) + changes_to_dos.service_histories.add_standard_opening_times_change( + current_opening_times=changes_to_dos.dos_service.standard_opening_times, + new_opening_times=changes_to_dos.nhs_entity.standard_opening_times, + dos_weekday_change_key=dos_weekday_key, + weekday=weekday, + ) + + if compare_specified_opening_times(changes=changes_to_dos): + changes_to_dos.specified_opening_times_changes = True + changes_to_dos.service_histories.add_specified_opening_times_change( + current_opening_times=changes_to_dos.current_specified_opening_times, + new_opening_times=changes_to_dos.new_specified_opening_times, + ) + else: + logger.info( + "Opening times are not valid", + nhs_uk_standard_opening_times=changes_to_dos.nhs_entity.standard_opening_times, + nhs_uk_specified_opening_times=changes_to_dos.nhs_entity.specified_opening_times, + ) + return changes_to_dos + + +def check_palliative_care_for_change(changes_to_dos: ChangesToDoS) -> ChangesToDoS: + """Compares and creates change for palliative care if needed. + + Args: + changes_to_dos (ChangesToDoS): ChangesToDoS holder object + + Returns: + ChangesToDoS: ChangesToDoS holder object + """ + skip_palliative_care_check = skip_if_key_is_none( + changes_to_dos.nhs_entity.check_for_uec_service(NHS_UK_PALLIATIVE_CARE_SERVICE_CODE), + ) + if ( + changes_to_dos.dos_service.typeid == DOS_PALLIATIVE_CARE_TYPE_ID + and len(changes_to_dos.dos_service.odscode) == MAIN_PHARMACY_ODSCODE_LENGTH + and compare_palliative_care(changes=changes_to_dos) + and skip_palliative_care_check is False + ): + changes_to_dos.palliative_care_changes = True + + changes_to_dos.service_histories.add_sgsdid_change( + sgsdid=DOS_PALLIATIVE_CARE_SGSDID, + new_value=changes_to_dos.nhs_entity.palliative_care, + ) + else: + logger.info( + "No change / Not suitable for palliative care comparison", + nhs_uk_palliative_care=get_palliative_care_log_value( + changes_to_dos.nhs_entity.palliative_care, + skip_palliative_care_check, + ), + dos_palliative_care=changes_to_dos.dos_service.palliative_care, + dos_odscode=changes_to_dos.dos_service.odscode, + skip_palliative_care_check=skip_palliative_care_check, + ) + return changes_to_dos + + +def check_blood_pressure_for_change(changes_to_dos: ChangesToDoS) -> ChangesToDoS: + """Compares and creates change for blood pressure if needed. + + Args: + changes_to_dos (ChangesToDoS): ChangesToDoS holder object + + Returns: + ChangesToDoS: ChangesToDoS holder object + """ + if changes_to_dos.dos_service.typeid != DOS_BLOOD_PRESSURE_TYPE_ID: + logger.info("Not Suitable for blood pressure comparison", dos_service_type_id=changes_to_dos.dos_service.typeid) + return changes_to_dos + + if compare_blood_pressure(changes=changes_to_dos): + changes_to_dos.blood_pressure_changes = True + status_id_change( + changes_to_dos=changes_to_dos, + new_value=changes_to_dos.nhs_entity.blood_pressure, + previous_value=changes_to_dos.dos_service.status_name, + ) + else: + logger.info( + "No change to blood pressure", + dos_service_type_id=changes_to_dos.dos_service.typeid, + nhs_uk_blood_pressure=changes_to_dos.nhs_entity.blood_pressure, + dos_blood_pressure=changes_to_dos.dos_service.status_name, + ) + + return changes_to_dos + + +def check_contraception_for_change(changes_to_dos: ChangesToDoS) -> ChangesToDoS: + """Compares and creates change for contraception if needed. + + Args: + changes_to_dos (ChangesToDoS): ChangesToDoS holder object + + Returns: + ChangesToDoS: ChangesToDoS holder object + """ + if changes_to_dos.dos_service.typeid != DOS_CONTRACEPTION_TYPE_ID: + logger.info("Not Suitable for contraception comparison", dos_service_type_id=changes_to_dos.dos_service.typeid) + return changes_to_dos + + if compare_contraception(changes=changes_to_dos): + changes_to_dos.contraception_changes = True + status_id_change( + changes_to_dos=changes_to_dos, + new_value=changes_to_dos.nhs_entity.contraception, + previous_value=changes_to_dos.dos_service.status_name, + ) + else: + logger.info( + "No change to contraception", + dos_service_type_id=changes_to_dos.dos_service.typeid, + nhs_uk_contraception=changes_to_dos.nhs_entity.contraception, + dos_contraception=changes_to_dos.dos_service.status_name, + ) + + return changes_to_dos + + +def services_change( + changes_to_dos: ChangesToDoS, + change_key: str, + new_value: Any, # noqa: ANN401 + previous_value: Any, # noqa: ANN401 + service_table_field_name: str, + update_service_history: bool = True, +) -> ChangesToDoS: + """Runs the prerequisites for a change to the services table. + + Including adding the change to the change object, and updating the service history. + + Args: + changes_to_dos (ChangesToDoS): The changes to dos object + change_key (str): The service history change key + new_value (Any): The new value to set the service table field to + previous_value (Any): The previous value of the service table field + service_table_field_name (str): The name of the service table field to set + update_service_history (bool): Whether to update the service history. Defaults to True. + + Returns: + ChangesToDoS: The changes to dos object + """ + changes_to_dos.demographic_changes[service_table_field_name] = str(new_value) if new_value is not None else "" + if update_service_history: + changes_to_dos.service_histories.add_change( + dos_change_key=change_key, + change=ServiceHistoriesChange( + data=new_value, + previous_value=previous_value, + change_key=change_key, + ), + ) + return changes_to_dos + + +def status_id_change(changes_to_dos: ChangesToDoS, new_value: bool, previous_value: str) -> ChangesToDoS: + """Runs the prerequisites for a change to the services table. + + Including adding the change to the change object, and updating the service history. + + Args: + changes_to_dos (ChangesToDoS): The changes to dos object + new_value (int): The new value to set the service table field to + previous_value (str): The previous value of the service table field + + Returns: + ChangesToDoS: The changes to dos object + """ + status_id, status_name = ( + DOS_ACTIVE_STATUS_ID if new_value else DOS_CLOSED_STATUS_ID, + DOS_SERVICE_HISTORY_ACTIVE_STATUS if new_value else DOS_SERVICE_HISTORY_CLOSED_STATUS, + ) + changes_to_dos.demographic_changes["statusid"] = status_id + changes_to_dos.service_histories.add_change( + dos_change_key=DOS_STATUS_CHANGE_KEY, + change=ServiceHistoriesChange( + data=status_name, + previous_value=previous_value, + change_key=DOS_STATUS_CHANGE_KEY, + ), + ) + return changes_to_dos diff --git a/application/service_sync/data_processing/comparison.py b/application/service_sync/data_processing/comparison.py new file mode 100644 index 000000000..dc316dfeb --- /dev/null +++ b/application/service_sync/data_processing/comparison.py @@ -0,0 +1,228 @@ +from aws_lambda_powertools.logging import Logger + +from ..reporting import log_invalid_nhsuk_postcode +from .changes_to_dos import ChangesToDoS +from .formatting import format_address, format_public_phone, format_website +from .validation import validate_website +from common.commissioned_service_type import BLOOD_PRESSURE, CONTRACEPTION, PALLIATIVE_CARE, CommissionedServiceType +from common.dos import get_valid_dos_location +from common.dos_location import DoSLocation +from common.opening_times import ( + SpecifiedOpeningTime, + StandardOpeningTimes, + opening_period_times_from_list, +) +from common.utilities import is_val_none_or_empty + +logger = Logger(child=True) + + +def compare_website(changes: ChangesToDoS) -> bool: + """Compares the website of from the dos_service and nhs_entity.""" + if is_val_none_or_empty(changes.nhs_entity.website) and not is_val_none_or_empty(changes.dos_service.web): + # Deleting the existing website + changes.current_website = changes.dos_service.web + changes.new_website = None + return True + elif changes.nhs_entity.website is not None and changes.nhs_entity.website: # noqa: RET505 + changes.current_website = changes.dos_service.web + # Adding a new website + nhs_uk_website = format_website(changes.nhs_entity.website) + changes.new_website = nhs_uk_website + if changes.dos_service.web != nhs_uk_website: + logger.info(f"Website is not equal, {changes.dos_service.web=} != {nhs_uk_website=}") + return validate_website(changes.nhs_entity, nhs_uk_website, changes.dos_service) + logger.info(f"Website is equal, {changes.dos_service.web=} == {nhs_uk_website=}") + return False + + +def compare_public_phone(changes: ChangesToDoS) -> bool: + """Compares the public phone of from the dos_service and nhs_entity. + + Returns: + bool: True if the public phone has changed, False if not + """ + changes.current_public_phone = changes.dos_service.publicphone + changes.new_public_phone = ( + changes.nhs_entity.phone + if is_val_none_or_empty(changes.nhs_entity.phone) + else format_public_phone(changes.nhs_entity.phone) + ) + if str(changes.current_public_phone) != changes.new_public_phone: + logger.info( + f"Public Phone is not equal, DoS='{changes.current_public_phone}' != NHS UK='{changes.new_public_phone}'", + ) + return True + logger.info(f"Public Phone is equal, DoS='{changes.current_public_phone}' == NHS UK='{changes.new_public_phone}'") + return False + + +def compare_location(changes: ChangesToDoS) -> tuple[bool, bool, DoSLocation | None]: + """Check if address and postcode have changed between dos_service and nhs_entity. + + Postcode changes are validated against the DoS locations table. + + Returns: + Tuple[bool, bool]: Tuple of booleans, first is if address has changed, second is if postcode has changed, third is the DoSLocation object for the postcode + """ # noqa: E501 + before_title_case_address = changes.nhs_entity.address_lines + changes.nhs_entity.address_lines = list(map(format_address, changes.nhs_entity.address_lines)) + logger.debug( + f"Address after title casing: {changes.nhs_entity.address_lines}", + before=before_title_case_address, + after=changes.nhs_entity.address_lines, + ) + nhs_uk_address_string = "$".join(changes.nhs_entity.address_lines) + dos_address = changes.dos_service.address + is_address_same = True + if dos_address != nhs_uk_address_string: + is_address_same = False + logger.info(f"Address is not equal, {dos_address=} != {nhs_uk_address_string=}") + changes.new_address = nhs_uk_address_string + changes.current_address = dos_address + else: + logger.info(f"Address is equal, {dos_address=} == {nhs_uk_address_string=}") + + dos_postcode = changes.dos_service.normal_postcode() + nhs_postcode = changes.nhs_entity.normal_postcode() + is_postcode_same = True + valid_dos_location = None + if dos_postcode != nhs_postcode: + logger.info(f"Postcode is not equal, {dos_postcode=} != {nhs_postcode=}") + valid_dos_location = get_valid_dos_location(nhs_postcode) + valid_dos_postcode = valid_dos_location.postcode if valid_dos_location else None + if valid_dos_postcode is None: + log_invalid_nhsuk_postcode(changes.nhs_entity, changes.dos_service) + if not is_address_same: + is_address_same = True + changes.new_address = None + changes.current_address = None + logger.info("Deleted address change as postcode is invalid") + else: + if is_address_same: + logger.info(f"Address is equal but Postcode is not equal, {dos_postcode=} != {nhs_postcode=}") + changes.new_postcode = valid_dos_postcode + changes.current_postcode = changes.dos_service.postcode + is_postcode_same = False + else: + logger.info(f"Postcode are equal, {dos_postcode=} == {nhs_postcode=}") + return not is_address_same, not is_postcode_same, valid_dos_location + + +def compare_standard_opening_times(changes: ChangesToDoS, weekday: str) -> bool: + """Check if the standard opening times have changed for a specific day. + + Args: + changes (ChangesToDoS): ChangesToDoS holder object + weekday (str): The day of the week lowercase to check (e.g. "monday") + + Returns: + bool: If there are changes to the standard opening times (not valiated) + """ + dos_standard_open_dates: StandardOpeningTimes = changes.dos_service.standard_opening_times + nhs_standard_open_dates: StandardOpeningTimes = changes.nhs_entity.standard_opening_times + dos_opening_times = dos_standard_open_dates.get_openings(weekday) + nhs_opening_times = nhs_standard_open_dates.get_openings(weekday.title()) + if not dos_standard_open_dates.same_openings(nhs_standard_open_dates, weekday): + logger.info( + f"{weekday.title()} opening times not equal. " + f"dos={opening_period_times_from_list(dos_opening_times)}, " + f"nhs={opening_period_times_from_list(nhs_opening_times)}", + ) + # Set variable for the correct day + setattr(changes, f"current_{weekday}_opening_times", dos_opening_times) + setattr(changes, f"new_{weekday}_opening_times", nhs_opening_times) + return True + logger.info( + f"{weekday.title()} opening times are equal, so no change. " + f"dos={opening_period_times_from_list(dos_opening_times)} " + f"nhs={opening_period_times_from_list(nhs_opening_times)}", + ) + return False + + +def compare_specified_opening_times(changes: ChangesToDoS) -> bool: + """Check if the specified opening times have changed. + + Also past specified opening times are removed from the comparison. + + Returns: + bool: If there are changes to the specified opening times (not validated) + """ + dos_spec_open_dates = changes.dos_service.specified_opening_times + nhs_spec_open_dates = changes.nhs_entity.specified_opening_times + future_nhs_spec_open_dates = SpecifiedOpeningTime.remove_past_dates(changes.nhs_entity.specified_opening_times) + if len(nhs_spec_open_dates) != len(future_nhs_spec_open_dates): + logger.info( + "Removing Specified opening times that occur in the past", + all_nhs=nhs_spec_open_dates, + future_nhs=future_nhs_spec_open_dates, + ) + if SpecifiedOpeningTime.equal_lists(dos_spec_open_dates, future_nhs_spec_open_dates): + logger.info( + "Specified opening times are equal, so no change", + dos=dos_spec_open_dates, + nhs=future_nhs_spec_open_dates, + ) + return False + + logger.info( + "Specified opening times not equal", + dos=dos_spec_open_dates, + nhs=future_nhs_spec_open_dates, + ) + changes.current_specified_opening_times = dos_spec_open_dates + changes.new_specified_opening_times = future_nhs_spec_open_dates + return True + + +def compare_palliative_care(changes: ChangesToDoS) -> bool: + """Compares the palliative care of from the dos_service and nhs_entity. + + Returns: + bool: True if the palliative care is different, False if not + """ + return compare_commissioned_service(changes=changes, service_type=PALLIATIVE_CARE) + + +def compare_blood_pressure(changes: ChangesToDoS) -> bool: + """Compares the blood pressure of from the dos_service and nhs_entity. + + Returns: + bool: True if the blood pressure is different, False if not + """ + return compare_commissioned_service(changes=changes, service_type=BLOOD_PRESSURE) + + +def compare_contraception(changes: ChangesToDoS) -> bool: + """Compares the blood pressure services of the dos_service and nhs_entity. + + Returns: + bool: True if the blood pressure is different, False if not + """ + return compare_commissioned_service(changes=changes, service_type=CONTRACEPTION) + + +def compare_commissioned_service(changes: ChangesToDoS, service_type: CommissionedServiceType) -> bool: + """Compares the same sub service of the dos_service and nhs_entity. + + Returns: + bool: True if the sub service is different, False if not + """ + type_name = service_type.TYPE_NAME.replace(" ", "_").lower() + current_comm_service = getattr(changes.dos_service, type_name, None) + setattr(changes, f"current_{type_name}", current_comm_service) + new_comm_service = getattr(changes.nhs_entity, type_name, None) + setattr(changes, f"new_{type_name}", new_comm_service) + + if current_comm_service != new_comm_service: + logger.info( + f"{service_type.TYPE_NAME} is not equal, DoS='{current_comm_service}' != NHS UK='{new_comm_service}'", + kwargs={ + f"dos_{service_type.TYPE_NAME}": current_comm_service, + f"nhsuk_{service_type.TYPE_NAME}": new_comm_service, + }, + ) + return True + logger.info(f"{service_type.TYPE_NAME} is equal, DoS='{current_comm_service}' == NHS UK='{new_comm_service}'") + return False diff --git a/application/service_sync/data_processing/formatting.py b/application/service_sync/data_processing/formatting.py new file mode 100644 index 000000000..652fe401f --- /dev/null +++ b/application/service_sync/data_processing/formatting.py @@ -0,0 +1,61 @@ +from re import sub +from urllib.parse import urlparse, urlunparse + +from aws_lambda_powertools import Logger + +logger = Logger() + + +def format_address(address: str) -> str: + """Formats an address line to title case and removes apostrophes. As well it replaces any '&' symbols with and. + + Args: + address (str): Address line to format + + Returns: + str: Formatted address line + """ + # Capitalise first letter of each word + formatted_address = sub(r"[A-Za-z]+('[A-Za-z]+)?", lambda word: word.group(0).capitalize(), address) + formatted_address = formatted_address.replace("'", "") # Remove apostrophes + formatted_address = formatted_address.replace("&", "and") # Replace '&' with 'and' + logger.debug("Formatted address line", prior_address=address, formatted_address=formatted_address) + return formatted_address + + +def format_website(website: str) -> str: + """Formats a website to lowercase and removes trailing slash. + + Args: + website (str): Website to format + + Returns: + str: Formatted website + """ + nhs_uk_website = urlparse(website) + if nhs_uk_website.netloc: # handle websites like https://www.test.com + nhs_uk_website = nhs_uk_website._replace(netloc=nhs_uk_website.netloc.lower()) + nhs_uk_website = urlunparse(nhs_uk_website) + elif "/" in website: + nhs_uk_website = website.split("/") + nhs_uk_website[0] = nhs_uk_website[0].lower() + nhs_uk_website = "/".join(nhs_uk_website) + else: # handle website like www.test.com + nhs_uk_website = urlunparse(nhs_uk_website).lower() + logger.debug("Formatted website", prior_website=website, formatted_website=nhs_uk_website) + return nhs_uk_website + + +def format_public_phone(phone: str) -> str: + """Formats a phone number to remove spaces. + + Args: + phone (str): Phone number to format + + Returns: + str: Formatted phone number + """ + formatted_phone = phone.strip() + formatted_phone = formatted_phone.replace(" ", "") + logger.debug("Formatted phone", prior_phone=phone, formatted_phone=formatted_phone) + return formatted_phone diff --git a/application/service_sync/data_processing/get_data.py b/application/service_sync/data_processing/get_data.py new file mode 100644 index 000000000..4f0102964 --- /dev/null +++ b/application/service_sync/data_processing/get_data.py @@ -0,0 +1,72 @@ +from aws_lambda_powertools.logging import Logger +from psycopg.rows import DictRow + +from .service_histories import ServiceHistories +from common.dos import ( + DoSService, + get_specified_opening_times_from_db, + get_standard_opening_times_from_db, + has_blood_pressure, + has_contraception, + has_palliative_care, +) +from common.dos_db_connection import connect_to_db_writer, query_dos_db + +logger = Logger(child=True) + + +def get_dos_service_and_history(service_id: int) -> tuple[DoSService, ServiceHistories]: + """Retrieves DoS Services from DoS database. + + Args: + service_id (str): Id of service to retrieve + + Returns: + Tuple[DoSService, ServiceHistories]: Tuple of DoS service and service history + + """ + sql_query = ( + "SELECT s.id, uid, s.name, odscode, address, town, postcode, web, typeid, statusid, ss.name status_name, " + "publicphone, publicname, st.name service_type_name, easting, northing, latitude, longitude FROM services s " + "LEFT JOIN servicetypes st ON s.typeid = st.id LEFT JOIN servicestatuses ss on s.statusid = ss.id " + "WHERE s.id = %(SERVICE_ID)s" + ) + query_vars = {"SERVICE_ID": service_id} + # Connect to the DoS database + with connect_to_db_writer() as connection: + # Query the DoS database for the service + cursor = query_dos_db(connection=connection, query=sql_query, query_vars=query_vars) + rows: list[DictRow] = cursor.fetchall() + if len(rows) == 1: + # Select first row (service) and create DoSService object + service = DoSService(rows[0]) + logger.append_keys(service_name=service.name) + logger.append_keys(service_uid=service.uid) + logger.append_keys(type_id=service.typeid) + elif not rows: + msg = f"Service ID {service_id} not found" + raise ValueError(msg) + else: + msg = f"Multiple services found for Service Id: {service_id}" + raise ValueError(msg) + # Set up remaining service data + service.standard_opening_times = get_standard_opening_times_from_db( + connection=connection, + service_id=service_id, + ) + service.specified_opening_times = get_specified_opening_times_from_db( + connection=connection, + service_id=service_id, + ) + # Set up palliative care flag + service.palliative_care = has_palliative_care(service=service, connection=connection) + # Set up blood pressure flag + service.blood_pressure = has_blood_pressure(service=service) + # Set up contraception flag + service.contraception = has_contraception(service=service) + # Set up service history + service_histories = ServiceHistories(service_id=service_id) + service_histories.get_service_history_from_db(connection) + service_histories.create_service_histories_entry() + # Connection closed by context manager + return service, service_histories diff --git a/application/service_sync/data_processing/service_histories.py b/application/service_sync/data_processing/service_histories.py new file mode 100644 index 000000000..a951fdca3 --- /dev/null +++ b/application/service_sync/data_processing/service_histories.py @@ -0,0 +1,257 @@ +from datetime import datetime +from itertools import chain +from json import dumps, loads +from time import time +from typing import Any, Self + +from aws_lambda_powertools.logging import Logger +from psycopg import Connection +from psycopg.rows import dict_row +from pytz import timezone + +from .service_histories_change import ServiceHistoriesChange +from common.constants import ( + DOS_CLINICAL_AREA_TYPE, + DOS_INTEGRATION_USER_NAME, + DOS_SGSDID_CHANGE_KEY, + DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, +) +from common.dos_db_connection import query_dos_db +from common.opening_times import SpecifiedOpeningTime, StandardOpeningTimes + +logger = Logger(child=True) + + +class ServiceHistories: + """A service to be added to the servicehistories table.""" + + NEW_CHANGE_KEY: str + service_history: dict[str, Any] + existing_service_history: dict[str, Any] + service_id: int + history_already_exists: bool + + def __init__(self: Self, service_id: int) -> None: + """Initialises the ServiceHistories object. + + Args: + service_id (int): The service id of the service to be added to the servicehistories table. + """ + # Epoch time in seconds rounded down to the nearest second + self.current_epoch_time = int(time()) + # Use same date/time from epoch time and format it to DoS date/time format + self.service_id = service_id + self.history_already_exists = False + self.existing_service_history = {} + self.service_history = {} + self.NEW_CHANGE_KEY = "new_change" + + def get_service_history_from_db(self: Self, connection: Connection) -> None: + """Gets the service_histories json from the database. + + Args: + connection (Connection): The connection to the database + """ + cursor = connection.cursor(row_factory=dict_row) + # Get the history json from the database for the service + cursor.execute( + query="Select history from servicehistories where serviceid = %(SERVICE_ID)s", + params={"SERVICE_ID": self.service_id}, + ) + if results := cursor.fetchall(): + # Change History exists in the database + logger.debug(f"Service history exists in the database for serviceid {self.service_id}") + service_history = results[0]["history"] + self.existing_service_history = loads(service_history) + self.history_already_exists = True + else: + # Change History does not exist in the database + self.existing_service_history = {} + self.history_already_exists = False + + def create_service_histories_entry(self: Self) -> None: + """Creates a new entry in the service_histories json for any changes that will be made to the service.""" + self.service_history[self.NEW_CHANGE_KEY] = { + "new": {}, + "initiator": {"userid": DOS_INTEGRATION_USER_NAME, "timestamp": "TBD"}, + "approver": {"userid": DOS_INTEGRATION_USER_NAME, "timestamp": "TBD"}, + } # Timestamp will be created when the change is sent to db for it to be realtime + + def add_change(self: Self, dos_change_key: str, change: ServiceHistoriesChange) -> None: + """Adds a change to the updated service_histories json.""" + self.service_history[self.NEW_CHANGE_KEY]["new"][dos_change_key] = change.get_change() + + def add_standard_opening_times_change( + self: Self, + current_opening_times: StandardOpeningTimes, + new_opening_times: StandardOpeningTimes, + weekday: str, + dos_weekday_change_key: str, + ) -> ServiceHistoriesChange: + """Adds a standard opening times change to the updated service_histories json. + + Args: + current_opening_times (StandardOpeningTimes): The current standard opening times + new_opening_times (StandardOpeningTimes): The new standard opening times + weekday (str): The weekday for the change + dos_weekday_change_key (str): The dos_weekday_change_key for the change + + Returns: + ServiceHistoriesChange: The change that was added to the service history + """ + data = {} + # Get the opening times in the format that is expected by the DoS Service History API + # Format is time in seconds e.g. "1000-2000" + current_opening_times_in_seconds = current_opening_times.export_opening_times_in_seconds_for_day(weekday) + new_opening_times_in_seconds = new_opening_times.export_opening_times_in_seconds_for_day(weekday) + # If either list is empty then it doesn't have any open periods, Therefore it's closed + if current_opening_times_in_seconds != []: + data["remove"] = current_opening_times_in_seconds + if new_opening_times_in_seconds != []: + data["add"] = new_opening_times_in_seconds + # Add the change to the service history + change = ServiceHistoriesChange( + change_key=dos_weekday_change_key, + previous_value=current_opening_times.export_opening_times_for_day(weekday), + data=data, + ) + self.add_change(dos_change_key=dos_weekday_change_key, change=change) + return change + + def add_specified_opening_times_change( + self: Self, + current_opening_times: list[SpecifiedOpeningTime], + new_opening_times: list[SpecifiedOpeningTime], + ) -> ServiceHistoriesChange: + """Adds a change to the updated service_histories json. + + Args: + current_opening_times (List[SpecifiedOpeningTime]): The current specified opening times + new_opening_times (List[SpecifiedOpeningTime]): The new specified opening times + + Returns: + ServiceHistoriesChange: The change that was added to the service history + """ + # Get the opening times in the format that is expected by the DoS Service History Table + current_specified_opening_times = self.get_formatted_specified_opening_times(current_opening_times) + new_specified_opening_times = self.get_formatted_specified_opening_times(new_opening_times) + data = {} + if current_specified_opening_times != [] and new_specified_opening_times != []: + # Data item modified + data["remove"] = current_specified_opening_times + data["add"] = new_specified_opening_times + elif current_specified_opening_times != []: + # Data item deleted + data["remove"] = current_specified_opening_times + elif new_specified_opening_times != []: + # Data item added + data["add"] = new_specified_opening_times + + change = ServiceHistoriesChange( + change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + previous_value=current_specified_opening_times, + data=data, + ) + # Add the change to the service history + self.add_change( + dos_change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + change=change, + ) + return change + + def add_sgsdid_change(self: Self, sgsdid: str, new_value: bool) -> ServiceHistoriesChange: + """Adds a change to the updated service_histories json. + + Args: + sgsdid (str): The sgsdid for the change + new_value (bool): The new value for the sgsdid + + Returns: + ServiceHistoriesChange: The change that was added to the service history + """ + # Get the previous value + add_or_remove = "add" if new_value else "remove" + previous_value = "" + # Add the change to the service history + change = ServiceHistoriesChange( + change_key=DOS_SGSDID_CHANGE_KEY, + previous_value=previous_value, + data={add_or_remove: [sgsdid]}, + area=DOS_CLINICAL_AREA_TYPE, + ) + self.add_change( + dos_change_key=DOS_SGSDID_CHANGE_KEY, + change=change, + ) + return change + + def get_formatted_specified_opening_times(self: Self, opening_times: list[SpecifiedOpeningTime]) -> list[str]: + """Returns the specified opening times in the format that is expected by the DoS Service History. + + Args: + opening_times (List[SpecifiedOpeningTime]): The specified opening times to be formatted + + Returns: + list: The formatted specified opening times + """ + # Get the opening times in the format that is expected by the DoS Service History Table + opening_times = [ + specified_opening_time.export_service_history_format() for specified_opening_time in opening_times + ] + return list(chain.from_iterable(opening_times)) + + def save_service_histories(self: Self, connection: Connection) -> None: + """Saves the service_histories json to the database. + + Args: + connection (connection): The database connection + """ + # Generate the epoch time in seconds rounded down to the nearest second at the time of saving + current_epoch_time = str(int(time())) + # Get local datetime and format it to DoS date/time format + current_date_time = datetime.now(timezone("Europe/London")).strftime("%Y-%m-%d %H:%M:%S") + # Rename the new_change key to the current epoch time + self.service_history[current_epoch_time] = self.service_history.pop("new_change") + # Add the current time to the service_histories json + self.service_history[current_epoch_time]["initiator"]["timestamp"] = current_date_time + self.service_history[current_epoch_time]["approver"]["timestamp"] = current_date_time + # Merge the new history changes into the existing history changes + json_service_history = dumps(self.service_history | self.existing_service_history) + logger.debug("Service history to be saved", service_history=json_service_history) + cursor = query_dos_db( + connection=connection, + query=( + """UPDATE services SET modifiedby=%(USER_NAME)s, """ + """modifiedtime=%(CURRENT_DATE_TIME)s WHERE id = %(SERVICE_ID)s;""" + ), + query_vars={ + "USER_NAME": DOS_INTEGRATION_USER_NAME, + "CURRENT_DATE_TIME": current_date_time, + "SERVICE_ID": self.service_id, + }, + ) + cursor.close() + if self.history_already_exists: + # Update the service_histories json in the database + cursor = query_dos_db( + connection=connection, + query=( + """UPDATE servicehistories SET history = %(SERVICE_HISTORY)s WHERE serviceid = %(SERVICE_ID)s;""" + ), + query_vars={"SERVICE_HISTORY": json_service_history, "SERVICE_ID": self.service_id}, + ) + logger.info(f"Service history updated for serviceid {self.service_id}") + cursor.close() + else: + # Create a new entry in the service_histories json for the service + cursor = query_dos_db( + connection=connection, + query=( + """INSERT INTO servicehistories (serviceid, history) """ + """VALUES (%(SERVICE_ID)s, %(SERVICE_HISTORY)s);""" + ), + query_vars={"SERVICE_ID": self.service_id, "SERVICE_HISTORY": json_service_history}, + ) + cursor.close() + logger.warning(f"Service history created in the database for serviceid {self.service_id}") + cursor.close() diff --git a/application/service_sync/data_processing/service_histories_change.py b/application/service_sync/data_processing/service_histories_change.py new file mode 100644 index 000000000..12aa79df3 --- /dev/null +++ b/application/service_sync/data_processing/service_histories_change.py @@ -0,0 +1,114 @@ +from dataclasses import dataclass +from typing import Any, Self + +from aws_lambda_powertools.logging import Logger + +from common.constants import ( + DI_CHANGE_KEYS_LIST, + DOS_DEMOGRAPHICS_AREA_TYPE, + DOS_SERVICES_TABLE_CHANGE_TYPE_LIST, + DOS_SGSDID_CHANGE_KEY, + DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_CHANGE_KEY_LIST, +) + +logger = Logger(child=True) + + +@dataclass(repr=True) +class ServiceHistoriesChange: + """A change to be added to the servicehistories table.""" + + data: str + previous_value: str + change_key: str + change_action: str + area: str + + def __init__( + self: Self, data: str, previous_value: str, change_key: str, area: str = DOS_DEMOGRAPHICS_AREA_TYPE + ) -> None: + """Initialises the ServiceHistoriesChange object. + + Args: + data (str): The data to be added to the servicehistories table. + previous_value (str): The previous value of the data to be added to the servicehistories table. + change_key (str): The change key for the data to be added to the servicehistories table. + area (str): The area of the data to be added to the servicehistories table. + Defaults to DOS_DEMOGRAPHICS_AREA_TYPE. + """ + self.data = data + self.previous_value = previous_value + self.change_key = change_key + self.area = area + if self.change_key in DOS_SERVICES_TABLE_CHANGE_TYPE_LIST or self.change_key in DI_CHANGE_KEYS_LIST: + self.change_action = self.get_demographics_change_action() + elif ( + self.change_key in DOS_STANDARD_OPENING_TIMES_CHANGE_KEY_LIST + or self.change_key == DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY + ): + self.change_action = self.get_opening_times_change_action() + elif self.change_key == DOS_SGSDID_CHANGE_KEY: + self.change_action = self.get_sgsd_change_action() + else: + logger.error(f"Unknown change key {self.change_key}") + msg = "Unknown change key" + raise ValueError(msg) + + def get_demographics_change_action(self: Self) -> str: + """Gets the change action for a demographics change. + + Returns: + str: Change action - add, delete, modify + """ + new_value = self.data + previous_value = self.previous_value + if previous_value is None or previous_value == "None" and new_value is not None: + return "add" + elif new_value is None: # noqa: RET505 + return "delete" + else: + return "modify" + + def get_sgsd_change_action(self: Self) -> str: + """Gets the change action for a sgsd change. + + Returns: + str: Change action - add, delete + """ + new_value: dict[list[str]] = self.data + value = next(iter(new_value.keys())) + return "add" if value == "add" else "delete" + + def get_opening_times_change_action(self: Self) -> str: + """Gets the change action for a opening times (specified or standard) change. + + Returns: + str: Change action - add, delete, modify + """ + if "remove" in self.data and "add" in self.data: + return "modify" + elif "remove" in self.data: # noqa: RET505 + return "delete" + elif "add" in self.data: + return "add" + else: + logger.error(f"Unknown change action from {self.data}") + msg = "Unknown change action" + raise ValueError(msg) + + def get_change(self: Self) -> dict[str, Any]: + """Gets the change to be added to the servicehistories table. + + Returns: + Dict[str, Any]: Change to be added to the servicehistories table + """ + change = { + "changetype": self.change_action, + "data": self.data, + "area": self.area, + "previous": self.previous_value, + } + if self.change_action == "add": + del change["previous"] + return change diff --git a/test/integration/steps/utilities/__init__.py b/application/service_sync/data_processing/tests/__init__.py similarity index 100% rename from test/integration/steps/utilities/__init__.py rename to application/service_sync/data_processing/tests/__init__.py diff --git a/application/service_sync/data_processing/tests/test_changes_to_dos.py b/application/service_sync/data_processing/tests/test_changes_to_dos.py new file mode 100644 index 000000000..dab262030 --- /dev/null +++ b/application/service_sync/data_processing/tests/test_changes_to_dos.py @@ -0,0 +1,31 @@ +from unittest.mock import MagicMock + +from application.service_sync.data_processing.changes_to_dos import ChangesToDoS + +FILE_PATH = "application.service_sync.data_processing.changes_to_dos" + + +def test_changes_to_dos() -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + # Act + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Assert + assert dos_service == changes_to_dos.dos_service + assert nhs_entity == changes_to_dos.nhs_entity + assert service_histories == changes_to_dos.service_histories + assert changes_to_dos.demographic_changes == {} + assert changes_to_dos.standard_opening_times_changes == {} + assert False is changes_to_dos.specified_opening_times_changes + assert None is changes_to_dos.new_address + assert None is changes_to_dos.new_postcode + assert None is changes_to_dos.new_public_phone + assert None is changes_to_dos.new_specified_opening_times + assert None is changes_to_dos.new_website + assert None is changes_to_dos.current_address + assert None is changes_to_dos.current_postcode + assert None is changes_to_dos.current_public_phone + assert None is changes_to_dos.current_specified_opening_times + assert None is changes_to_dos.current_website diff --git a/application/service_sync/data_processing/tests/test_check_for_change.py b/application/service_sync/data_processing/tests/test_check_for_change.py new file mode 100644 index 000000000..76c76691d --- /dev/null +++ b/application/service_sync/data_processing/tests/test_check_for_change.py @@ -0,0 +1,633 @@ +from unittest.mock import MagicMock, call, patch + +from application.common.constants import ( + DOS_ACTIVE_STATUS_ID, + DOS_ADDRESS_CHANGE_KEY, + DOS_CLOSED_STATUS_ID, + DOS_EASTING_CHANGE_KEY, + DOS_NORTHING_CHANGE_KEY, + DOS_POSTAL_TOWN_CHANGE_KEY, + DOS_POSTCODE_CHANGE_KEY, + DOS_PUBLIC_PHONE_CHANGE_KEY, + DOS_SERVICE_HISTORY_ACTIVE_STATUS, + DOS_SERVICE_HISTORY_CLOSED_STATUS, + DOS_STANDARD_OPENING_TIMES_FRIDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SATURDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SUNDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_THURSDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_TUESDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_WEDNESDAY_CHANGE_KEY, + DOS_STATUS_CHANGE_KEY, + DOS_WEBSITE_CHANGE_KEY, +) +from application.conftest import dummy_dos_location +from application.service_sync.data_processing.changes_to_dos import ChangesToDoS +from application.service_sync.data_processing.check_for_change import ( + check_blood_pressure_for_change, + check_contraception_for_change, + check_location_for_change, + check_opening_times_for_changes, + check_palliative_care_for_change, + check_public_phone_for_change, + check_website_for_change, + compare_nhs_uk_and_dos_data, + services_change, + status_id_change, +) + +FILE_PATH = "application.service_sync.data_processing.check_for_change" + + +@patch(f"{FILE_PATH}.check_contraception_for_change") +@patch(f"{FILE_PATH}.check_blood_pressure_for_change") +@patch(f"{FILE_PATH}.check_palliative_care_for_change") +@patch(f"{FILE_PATH}.check_opening_times_for_changes") +@patch(f"{FILE_PATH}.check_location_for_change") +@patch(f"{FILE_PATH}.check_public_phone_for_change") +@patch(f"{FILE_PATH}.check_website_for_change") +@patch(f"{FILE_PATH}.ChangesToDoS") +def test_compare_nhs_uk_and_dos_data( + mock_changes_to_dos: MagicMock, + mock_check_website_for_change: MagicMock, + mock_check_public_phone_for_change: MagicMock, + mock_check_location_for_change: MagicMock, + mock_check_opening_times_for_changes: MagicMock, + mock_check_palliative_care_for_change: MagicMock, + mock_check_blood_pressure_for_change: MagicMock, + mock_check_contraception_for_change: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + # Act + response = compare_nhs_uk_and_dos_data(dos_service, nhs_entity, service_histories) + # Assert + mock_changes_to_dos.assert_called_once_with( + dos_service=dos_service, + nhs_entity=nhs_entity, + service_histories=service_histories, + ) + mock_check_website_for_change.assert_called_once_with(changes_to_dos=mock_changes_to_dos.return_value) + mock_check_public_phone_for_change.assert_called_once_with( + changes_to_dos=mock_check_website_for_change.return_value, + ) + mock_check_location_for_change.assert_called_once_with( + changes_to_dos=mock_check_public_phone_for_change.return_value, + ) + mock_check_opening_times_for_changes.assert_called_once_with( + changes_to_dos=mock_check_location_for_change.return_value, + ) + mock_check_palliative_care_for_change.assert_called_once_with( + changes_to_dos=mock_check_opening_times_for_changes.return_value, + ) + mock_check_blood_pressure_for_change.assert_called_once_with( + changes_to_dos=mock_check_palliative_care_for_change.return_value, + ) + mock_check_contraception_for_change.assert_called_once_with( + changes_to_dos=mock_check_blood_pressure_for_change.return_value, + ) + assert response == mock_check_contraception_for_change.return_value + + +@patch(f"{FILE_PATH}.compare_website") +@patch(f"{FILE_PATH}.services_change") +def test_check_website_for_change(mock_services_change: MagicMock, mock_compare_website: MagicMock) -> None: + # Arrange + changes_to_dos = MagicMock() + mock_compare_website.return_value = True + # Act + response = check_website_for_change(changes_to_dos) + # Assert + assert response == mock_services_change.return_value + mock_compare_website.assert_called_once_with(changes=changes_to_dos) + mock_services_change.assert_called_once_with( + changes_to_dos=changes_to_dos, + change_key=DOS_WEBSITE_CHANGE_KEY, + new_value=changes_to_dos.new_website, + previous_value=changes_to_dos.current_website, + service_table_field_name="web", + ) + + +@patch(f"{FILE_PATH}.compare_website") +@patch(f"{FILE_PATH}.services_change") +def test_check_website_for_change_no_change( + mock_services_change: MagicMock, + mock_compare_website: MagicMock, +) -> None: + # Arrange + changes_to_dos = MagicMock() + mock_compare_website.return_value = False + # Act + response = check_website_for_change(changes_to_dos) + # Assert + assert response == changes_to_dos + mock_compare_website.assert_called_once_with(changes=changes_to_dos) + mock_services_change.assert_not_called() + + +@patch(f"{FILE_PATH}.compare_location") +@patch(f"{FILE_PATH}.services_change") +def test_check_location_for_change(mock_services_change: MagicMock, mock_compare_location: MagicMock) -> None: + # Arrange + changes_to_dos = MagicMock() + dos_location = dummy_dos_location() + mock_compare_location.return_value = [True, True, dos_location] + # Act + response = check_location_for_change(changes_to_dos) + # Assert + assert response == mock_services_change.return_value + mock_compare_location.assert_called_once_with(changes=changes_to_dos) + mock_services_change.assert_has_calls( + calls=[ + call( + changes_to_dos=changes_to_dos, + change_key=DOS_ADDRESS_CHANGE_KEY, + new_value=changes_to_dos.new_address, + previous_value=changes_to_dos.current_address, + service_table_field_name="address", + ), + call( + changes_to_dos=mock_services_change.return_value, + change_key=DOS_POSTCODE_CHANGE_KEY, + new_value=mock_services_change.return_value.new_postcode, + previous_value=mock_services_change.return_value.current_postcode, + service_table_field_name="postcode", + ), + call( + changes_to_dos=mock_services_change.return_value, + change_key=DOS_POSTAL_TOWN_CHANGE_KEY, + new_value=dos_location.postaltown, + previous_value=mock_services_change.return_value.dos_service.town, + service_table_field_name="town", + ), + call( + changes_to_dos=mock_services_change.return_value, + change_key=DOS_EASTING_CHANGE_KEY, + new_value=dos_location.easting, + previous_value=mock_services_change.return_value.dos_service.easting, + service_table_field_name="easting", + ), + call( + changes_to_dos=mock_services_change.return_value, + change_key=DOS_NORTHING_CHANGE_KEY, + new_value=dos_location.northing, + previous_value=mock_services_change.return_value.dos_service.northing, + service_table_field_name="northing", + ), + call( + changes_to_dos=mock_services_change.return_value, + change_key="latitude", + new_value=dos_location.latitude, + previous_value=mock_services_change.return_value.dos_service.latitude, + service_table_field_name="latitude", + update_service_history=False, + ), + call( + changes_to_dos=mock_services_change.return_value, + change_key="longitude", + new_value=dos_location.longitude, + previous_value=mock_services_change.return_value.dos_service.longitude, + service_table_field_name="longitude", + update_service_history=False, + ), + call().__eq__(mock_services_change.return_value), + ], + ) + + +@patch(f"{FILE_PATH}.compare_location") +@patch(f"{FILE_PATH}.services_change") +def test_check_location_for_change_no_changes( + mock_services_change: MagicMock, + mock_compare_location: MagicMock, +) -> None: + # Arrange + changes_to_dos = MagicMock() + mock_compare_location.return_value = False, False, None + # Act + response = check_location_for_change(changes_to_dos) + # Assert + assert response == changes_to_dos + mock_compare_location.assert_called_once_with(changes=changes_to_dos) + mock_services_change.assert_not_called() + + +@patch(f"{FILE_PATH}.compare_specified_opening_times") +@patch(f"{FILE_PATH}.compare_standard_opening_times") +@patch(f"{FILE_PATH}.services_change") +@patch(f"{FILE_PATH}.validate_opening_times") +def test_check_opening_times_for_changes( + mock_validate_opening_times: MagicMock, + mock_services_change: MagicMock, + mock_compare_standard_opening_times: MagicMock, + mock_compare_specified_opening_times: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + nhs_entity.standard_opening_times.fully_closed.return_value = False + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + changes_to_dos.new_monday_opening_times = "new_monday_opening_times" + changes_to_dos.new_tuesday_opening_times = "new_tuesday_opening_times" + changes_to_dos.new_wednesday_opening_times = "new_wednesday_opening_times" + changes_to_dos.new_thursday_opening_times = "new_thursday_opening_times" + changes_to_dos.new_friday_opening_times = "new_friday_opening_times" + changes_to_dos.new_saturday_opening_times = "new_saturday_opening_times" + changes_to_dos.new_sunday_opening_times = "new_sunday_opening_times" + mock_validate_opening_times.return_value = True + dos_service.standard_opening_times.same_openings.return_value = False + mock_compare_standard_opening_times.return_value = True + mock_compare_specified_opening_times.return_value = True + # Act + response = check_opening_times_for_changes(changes_to_dos) + # Assert + assert response == changes_to_dos + changes_to_dos.service_histories.add_standard_opening_times_change.assert_has_calls( + calls=[ + call( + current_opening_times=changes_to_dos.dos_service.standard_opening_times, + new_opening_times=changes_to_dos.nhs_entity.standard_opening_times, + dos_weekday_change_key=DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + weekday="monday", + ), + call( + current_opening_times=changes_to_dos.dos_service.standard_opening_times, + new_opening_times=changes_to_dos.nhs_entity.standard_opening_times, + dos_weekday_change_key=DOS_STANDARD_OPENING_TIMES_TUESDAY_CHANGE_KEY, + weekday="tuesday", + ), + call( + current_opening_times=changes_to_dos.dos_service.standard_opening_times, + new_opening_times=changes_to_dos.nhs_entity.standard_opening_times, + dos_weekday_change_key=DOS_STANDARD_OPENING_TIMES_WEDNESDAY_CHANGE_KEY, + weekday="wednesday", + ), + call( + current_opening_times=changes_to_dos.dos_service.standard_opening_times, + new_opening_times=changes_to_dos.nhs_entity.standard_opening_times, + dos_weekday_change_key=DOS_STANDARD_OPENING_TIMES_THURSDAY_CHANGE_KEY, + weekday="thursday", + ), + call( + current_opening_times=changes_to_dos.dos_service.standard_opening_times, + new_opening_times=changes_to_dos.nhs_entity.standard_opening_times, + dos_weekday_change_key=DOS_STANDARD_OPENING_TIMES_FRIDAY_CHANGE_KEY, + weekday="friday", + ), + call( + current_opening_times=changes_to_dos.dos_service.standard_opening_times, + new_opening_times=changes_to_dos.nhs_entity.standard_opening_times, + dos_weekday_change_key=DOS_STANDARD_OPENING_TIMES_SATURDAY_CHANGE_KEY, + weekday="saturday", + ), + call( + current_opening_times=changes_to_dos.dos_service.standard_opening_times, + new_opening_times=changes_to_dos.nhs_entity.standard_opening_times, + dos_weekday_change_key=DOS_STANDARD_OPENING_TIMES_SUNDAY_CHANGE_KEY, + weekday="sunday", + ), + ], + ) + changes_to_dos.service_histories.add_specified_opening_times_change.assert_called_once_with( + current_opening_times=None, + new_opening_times=None, + ) + + +@patch(f"{FILE_PATH}.validate_opening_times") +def test_check_opening_times_for_changes_invalid_opening_times( + mock_validate_opening_times: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + mock_validate_opening_times.return_value = False + changes_to_dos.nhs_entity.standard_opening_times.fully_closed.return_value = False + # Act + response = check_opening_times_for_changes(changes_to_dos) + # Assert + assert response == changes_to_dos + changes_to_dos.service_histories.add_standard_opening_times_change.assert_not_called() + changes_to_dos.service_histories.add_specified_opening_times_change.assert_not_called() + + +@patch(f"{FILE_PATH}.validate_opening_times") +def test_check_opening_times_for_changes_blank_opening_times( + mock_validate_opening_times: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + changes_to_dos.nhs_entity.standard_opening_times.fully_closed.return_value = True + mock_validate_opening_times.return_value = False + # Act + response = check_opening_times_for_changes(changes_to_dos) + # Assert + assert response == changes_to_dos + changes_to_dos.service_histories.add_standard_opening_times_change.assert_not_called() + changes_to_dos.service_histories.add_specified_opening_times_change.assert_not_called() + + +@patch(f"{FILE_PATH}.compare_specified_opening_times") +@patch(f"{FILE_PATH}.compare_standard_opening_times") +@patch(f"{FILE_PATH}.services_change") +@patch(f"{FILE_PATH}.validate_opening_times") +def test_check_opening_times_for_changes_no_change( + mock_validate_opening_times: MagicMock, + mock_services_change: MagicMock, + mock_compare_standard_opening_times: MagicMock, + mock_compare_specified_opening_times: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + changes_to_dos.new_monday_opening_times = "new_monday_opening_times" + changes_to_dos.new_tuesday_opening_times = "new_tuesday_opening_times" + changes_to_dos.new_wednesday_opening_times = "new_wednesday_opening_times" + changes_to_dos.new_thursday_opening_times = "new_thursday_opening_times" + changes_to_dos.new_friday_opening_times = "new_friday_opening_times" + changes_to_dos.new_saturday_opening_times = "new_saturday_opening_times" + changes_to_dos.new_sunday_opening_times = "new_sunday_opening_times" + mock_validate_opening_times.return_value = True + dos_service.standard_opening_times.same_openings.return_value = False + mock_compare_standard_opening_times.return_value = False + mock_compare_specified_opening_times.return_value = False + # Act + response = check_opening_times_for_changes(changes_to_dos) + # Assert + assert response == changes_to_dos + changes_to_dos.service_histories.add_standard_opening_times_change.assert_not_called() + changes_to_dos.service_histories.add_specified_opening_times_change.assert_not_called() + + +@patch(f"{FILE_PATH}.services_change") +@patch(f"{FILE_PATH}.compare_public_phone") +def test_check_public_phone_for_change_change( + mock_compare_public_phone: MagicMock, + mock_set_up_for_service_table_change: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.publicphone = "01234 567890" + nhs_entity.phone = "08976 543210" + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + changes_to_dos.current_public_phone = dos_service.publicphone + changes_to_dos.new_public_phone = nhs_entity.phone + mock_compare_public_phone.return_value = True + mock_set_up_for_service_table_change.return_value = changes_to_dos + # Act + response = check_public_phone_for_change(changes_to_dos) + # Assert + mock_compare_public_phone.assert_called_once_with(changes=changes_to_dos) + mock_set_up_for_service_table_change.assert_called_once_with( + changes_to_dos=changes_to_dos, + change_key=DOS_PUBLIC_PHONE_CHANGE_KEY, + new_value=changes_to_dos.new_public_phone, + previous_value=changes_to_dos.current_public_phone, + service_table_field_name="publicphone", + ) + assert response == changes_to_dos + + +@patch(f"{FILE_PATH}.services_change") +@patch(f"{FILE_PATH}.compare_public_phone") +def test_check_public_phone_for_change_no_change( + mock_compare_public_phone: MagicMock, + mock_set_up_for_service_table_change: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.publicphone = "01234 567890" + nhs_entity.phone = "01234 567890" + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + changes_to_dos.current_public_phone = dos_service.publicphone + changes_to_dos.new_public_phone = nhs_entity.phone + mock_compare_public_phone.return_value = False + mock_set_up_for_service_table_change.return_value = changes_to_dos + # Act + response = check_public_phone_for_change(changes_to_dos) + # Assert + mock_compare_public_phone.assert_called_once_with(changes=changes_to_dos) + mock_set_up_for_service_table_change.assert_not_called() + assert response == changes_to_dos + + +def test_check_palliative_care_for_change_unequal() -> None: + # Arrange + dos_service = MagicMock() + dos_service.odscode = "12345" + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.typeid = 13 + dos_service.palliative_care = True + nhs_entity.palliative_care = False + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Act + response = check_palliative_care_for_change(changes_to_dos) + # Assert + assert response == changes_to_dos + + +@patch(f"{FILE_PATH}.get_palliative_care_log_value") +def test_check_palliative_care_for_change_incorrect_odscode_length( + mock_get_palliative_care_log_value: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + dos_service.odscode = "123456" + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.typeid = 131 + dos_service.palliative_care = True + nhs_entity.palliative_care = nhs_palliative_care = False + mock_get_palliative_care_log_value.return_value = nhs_palliative_care + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Act + response = check_palliative_care_for_change(changes_to_dos) + # Assert + assert response == changes_to_dos + + +@patch(f"{FILE_PATH}.status_id_change") +@patch(f"{FILE_PATH}.compare_blood_pressure") +def test_check_blood_pressure_for_change( + mock_compare_blood_pressure: MagicMock, + mock_status_id_change: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.typeid = 148 + dos_service.palliative_care = True + nhs_entity.palliative_care = False + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + mock_compare_blood_pressure.return_value = True + # Act + check_blood_pressure_for_change(changes_to_dos=changes_to_dos) + # Assert + mock_compare_blood_pressure.assert_called_once_with(changes=changes_to_dos) + mock_status_id_change.assert_called_once_with( + changes_to_dos=changes_to_dos, + new_value=changes_to_dos.nhs_entity.blood_pressure, + previous_value=changes_to_dos.dos_service.status_name, + ) + + +@patch(f"{FILE_PATH}.status_id_change") +@patch(f"{FILE_PATH}.compare_contraception") +def test_check_contraception_for_change( + mock_compare_contraception: MagicMock, + mock_status_id_change: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.typeid = 149 + dos_service.palliative_care = True + nhs_entity.palliative_care = False + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + mock_compare_contraception.return_value = True + # Act + check_contraception_for_change(changes_to_dos=changes_to_dos) + # Assert + mock_compare_contraception.assert_called_once_with(changes=changes_to_dos) + mock_status_id_change.assert_called_once_with( + changes_to_dos=changes_to_dos, + new_value=changes_to_dos.nhs_entity.contraception, + previous_value=changes_to_dos.dos_service.status_name, + ) + + +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_services_change(mock_service_histories_change: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + change_key = "change_key" + new_value = "new_value" + previous_value = "previous_value" + service_table_field_name = "service_table_field_name" + # Act + response = services_change( + changes_to_dos=changes_to_dos, + change_key=change_key, + new_value=new_value, + previous_value=previous_value, + service_table_field_name=service_table_field_name, + update_service_history=True, + ) + # Assert + assert response == changes_to_dos + assert changes_to_dos.demographic_changes == {service_table_field_name: new_value} + mock_service_histories_change.assert_called_once_with( + data=new_value, + previous_value=previous_value, + change_key=change_key, + ) + changes_to_dos.service_histories.add_change.assert_called_once_with( + dos_change_key=change_key, + change=mock_service_histories_change.return_value, + ) + + +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_services_change_no_service_history_update(mock_service_histories_change: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + change_key = "change_key" + new_value = "new_value" + previous_value = "previous_value" + service_table_field_name = "service_table_field_name" + # Act + response = services_change( + changes_to_dos=changes_to_dos, + change_key=change_key, + new_value=new_value, + previous_value=previous_value, + service_table_field_name=service_table_field_name, + update_service_history=False, + ) + # Assert + assert response == changes_to_dos + assert changes_to_dos.demographic_changes == {service_table_field_name: new_value} + mock_service_histories_change.assert_not_called() + changes_to_dos.service_histories.add_change.assert_not_called() + + +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_status_id_change__active(mock_service_histories_change: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + new_value = True + previous_value = "previous_value" + # Act + response = status_id_change( + changes_to_dos=changes_to_dos, + new_value=new_value, + previous_value=previous_value, + ) + # Assert + assert response == changes_to_dos + assert changes_to_dos.demographic_changes == {"statusid": DOS_ACTIVE_STATUS_ID} + mock_service_histories_change.assert_called_once_with( + data=DOS_SERVICE_HISTORY_ACTIVE_STATUS, + previous_value=previous_value, + change_key=DOS_STATUS_CHANGE_KEY, + ) + changes_to_dos.service_histories.add_change.assert_called_once_with( + dos_change_key=DOS_STATUS_CHANGE_KEY, + change=mock_service_histories_change.return_value, + ) + + +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_status_id_change__closed(mock_service_histories_change: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + new_value = False + previous_value = "previous_value" + # Act + response = status_id_change( + changes_to_dos=changes_to_dos, + new_value=new_value, + previous_value=previous_value, + ) + # Assert + assert response == changes_to_dos + assert changes_to_dos.demographic_changes == {"statusid": DOS_CLOSED_STATUS_ID} + mock_service_histories_change.assert_called_once_with( + data=DOS_SERVICE_HISTORY_CLOSED_STATUS, + previous_value=previous_value, + change_key=DOS_STATUS_CHANGE_KEY, + ) + changes_to_dos.service_histories.add_change.assert_called_once_with( + dos_change_key=DOS_STATUS_CHANGE_KEY, + change=mock_service_histories_change.return_value, + ) diff --git a/application/service_sync/data_processing/tests/test_comparison.py b/application/service_sync/data_processing/tests/test_comparison.py new file mode 100644 index 000000000..ee9e0c1c4 --- /dev/null +++ b/application/service_sync/data_processing/tests/test_comparison.py @@ -0,0 +1,431 @@ +from unittest.mock import MagicMock, patch + +import pytest +from aws_lambda_powertools.logging import Logger + +from application.common.commissioned_service_type import CommissionedServiceType +from application.common.dos_location import DoSLocation +from application.common.opening_times import WEEKDAYS +from application.service_sync.data_processing.changes_to_dos import ChangesToDoS +from application.service_sync.data_processing.comparison import ( + compare_blood_pressure, + compare_commissioned_service, + compare_contraception, + compare_location, + compare_palliative_care, + compare_public_phone, + compare_specified_opening_times, + compare_standard_opening_times, + compare_website, +) + +FILE_PATH = "application.service_sync.data_processing.comparison" + + +@patch(f"{FILE_PATH}.is_val_none_or_empty") +@patch(f"{FILE_PATH}.format_website") +def test_compare_website_remove_website( + mock_format_website: MagicMock, + mock_is_val_none_or_empty: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + mock_is_val_none_or_empty.side_effect = [True, False] + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Act + response = compare_website(changes=changes_to_dos) + # Assert + assert True is response + mock_format_website.assert_not_called() + + +@patch(f"{FILE_PATH}.validate_website") +@patch(f"{FILE_PATH}.format_website") +@patch(f"{FILE_PATH}.is_val_none_or_empty") +@patch(f"{FILE_PATH}.format_website") +def test_compare_website_add_website( + mock_format_website: MagicMock, + mock_is_val_none_or_empty: MagicMock, + format_website: MagicMock, + mock_validate_website: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + format_website.return_value = nhs_website = "www.example2.com" + service_histories = MagicMock() + mock_is_val_none_or_empty.side_effect = [False, False] + mock_validate_website.return_value = True + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Act + response = compare_website(changes=changes_to_dos) + # Assert + assert True is response + assert dos_service.web == changes_to_dos.current_website + assert nhs_website == changes_to_dos.new_website + mock_format_website.assert_not_called() + mock_validate_website.assert_called_once_with(nhs_entity, nhs_website, dos_service) + + +@patch(f"{FILE_PATH}.validate_website") +@patch(f"{FILE_PATH}.is_val_none_or_empty") +@patch(f"{FILE_PATH}.format_website") +def test_compare_website_no_change( + mock_format_website: MagicMock, + mock_is_val_none_or_empty: MagicMock, + mock_validate_website: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + dos_service.web = "www.example2.com" + nhs_entity = MagicMock() + nhs_entity.website = dos_service.web + mock_format_website.return_value = dos_service.web + service_histories = MagicMock() + mock_is_val_none_or_empty.side_effect = [True, True] + mock_validate_website.return_value = True + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Act + response = compare_website(changes=changes_to_dos) + # Assert + assert False is response + mock_format_website.assert_called_once_with(nhs_entity.website) + mock_validate_website.assert_not_called() + + +@patch(f"{FILE_PATH}.get_valid_dos_location") +def test_compare_location(mock_get_valid_dos_location: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + dummy_dos_location = DoSLocation( + id=0, + postcode="DUMMY", + easting=0, + northing=0, + postaltown="STUB", + latitude=0, + longitude=0, + ) + mock_get_valid_dos_location.return_value = dummy_dos_location + # Act + address_response, postcode_response, dos_location = compare_location(changes_to_dos) + # Assert + assert True is address_response + assert True is postcode_response + assert None is not changes_to_dos.new_address + assert None is not changes_to_dos.new_postcode + assert None is not changes_to_dos.current_address + assert None is not changes_to_dos.current_postcode + mock_get_valid_dos_location.assert_called_once() + assert dummy_dos_location == dos_location + + +@patch(f"{FILE_PATH}.log_invalid_nhsuk_postcode") +@patch(f"{FILE_PATH}.get_valid_dos_location") +def test_compare_location_postcode_invalid( + mock_get_valid_dos_location: MagicMock, + mock_log_invalid_nhsuk_postcode: MagicMock, +) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + mock_get_valid_dos_location.return_value = None + # Act + address_response, postcode_response, dos_location = compare_location(changes_to_dos) + # Assert + assert False is address_response + assert False is postcode_response + assert None is changes_to_dos.new_address + assert None is changes_to_dos.new_postcode + assert None is changes_to_dos.current_address + assert None is changes_to_dos.current_postcode + mock_get_valid_dos_location.assert_called_once() + mock_log_invalid_nhsuk_postcode.assert_called_once() + assert mock_get_valid_dos_location.return_value == dos_location + + +@patch(f"{FILE_PATH}.get_valid_dos_location") +def test_has_location_changed_no_change(mock_get_valid_dos_location: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + dos_service.address = "1 Dummy Stub" + dos_service.normal_postcode.return_value = "DUMMY" + nhs_entity = MagicMock() + nhs_entity.address_lines = {dos_service.address} + nhs_entity.normal_postcode.return_value = "DUMMY" + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + + # Act + address_response, postcode_response, dos_location = compare_location(changes_to_dos) + # Assert + assert False is address_response + assert False is postcode_response + assert None is changes_to_dos.new_address + assert None is changes_to_dos.new_postcode + assert None is changes_to_dos.current_address + assert None is changes_to_dos.current_postcode + mock_get_valid_dos_location.assert_not_called() + + +def test_compare_public_phone() -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.publicphone = "0123456789" + nhs_entity.publicphone = "012345678" + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Act + response = compare_public_phone(changes=changes_to_dos) + # Assert + assert True is response + + +def test_compare_public_phone_no_change() -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.publicphone = "0123456789" + nhs_entity.phone = "0123456789" + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Act + response = compare_public_phone(changes=changes_to_dos) + # Assert + assert False is response + + +@pytest.mark.parametrize("weekday", WEEKDAYS) +def test_compare_standard_opening_times(weekday: str) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + dos_service.standard_opening_times.same_openings.return_value = False + # Act + compare_standard_opening_times(changes_to_dos, weekday) + # Assert + assert hasattr(changes_to_dos, f"current_{weekday}_opening_times") + assert hasattr(changes_to_dos, f"new_{weekday}_opening_times") + + +@pytest.mark.parametrize("weekday", WEEKDAYS) +def test_compare_standard_opening_times_no_changes(weekday: str) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + dos_service.standard_opening_times.same_openings.return_value = True + # Act + compare_standard_opening_times(changes_to_dos, weekday) + # Assert + assert False is hasattr(changes_to_dos, f"current_{weekday}_opening_times") + assert False is hasattr(changes_to_dos, f"new_{weekday}_opening_times") + + +@patch(f"{FILE_PATH}.SpecifiedOpeningTime") +def test_compare_specified_opening_times_changed(mock_specified_opening_time: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + mock_specified_opening_time.equal_lists.return_value = False + mock_specified_opening_time.remove_past_dates.return_value = ["2020-01-01"] + # Act + response = compare_specified_opening_times(changes=changes_to_dos) + # Assert + assert True is response + assert None is not changes_to_dos.new_specified_opening_times + assert None is not changes_to_dos.current_specified_opening_times + + +@patch(f"{FILE_PATH}.SpecifiedOpeningTime") +def test_compare_specified_opening_times_changed_no_change(mock_specified_opening_time: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + mock_specified_opening_time.equal_lists.return_value = True + # Act + response = compare_specified_opening_times(changes=changes_to_dos) + # Assert + assert False is response + assert None is changes_to_dos.new_specified_opening_times + assert None is changes_to_dos.current_specified_opening_times + + +def test_compare_palliative_care() -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.palliative_care = True + nhs_entity.palliative_care = False + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Act + response = compare_palliative_care(changes=changes_to_dos) + # Assert + assert True is response + + +def test_compare_palliative_care_no_change() -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.palliative_care = False + nhs_entity.palliative_care = False + changes_to_dos = ChangesToDoS(dos_service=dos_service, nhs_entity=nhs_entity, service_histories=service_histories) + # Act + response = compare_palliative_care(changes=changes_to_dos) + # Assert + assert False is response + + +def test_compare_blood_pressure_no_changes() -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.blood_pressure = True + nhs_entity.blood_pressure = True + changes_to_dos = ChangesToDoS( + dos_service=dos_service, + nhs_entity=nhs_entity, + service_histories=service_histories, + ) + # Act + response = compare_blood_pressure(changes=changes_to_dos) + # Assert + assert False is response + + +def test_compare_blood_pressure() -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.blood_pressure = True + nhs_entity.blood_pressure = False + changes_to_dos = ChangesToDoS( + dos_service=dos_service, + nhs_entity=nhs_entity, + service_histories=service_histories, + ) + + # Act + response = compare_blood_pressure(changes=changes_to_dos) + # Assert + assert True is response + + +def test_compare_contraception_pressure_no_changes() -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.contraception = True + nhs_entity.contraception = True + changes_to_dos = ChangesToDoS( + dos_service=dos_service, + nhs_entity=nhs_entity, + service_histories=service_histories, + ) + # Act + response = compare_contraception(changes=changes_to_dos) + # Assert + assert False is response + + +def test_compare_contraception() -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + dos_service.contraception = True + nhs_entity.contraception = False + changes_to_dos = ChangesToDoS( + dos_service=dos_service, + nhs_entity=nhs_entity, + service_histories=service_histories, + ) + # Act + response = compare_contraception(changes=changes_to_dos) + # Assert + assert True is response + + +@patch.object(Logger, "info") +def test_compare_commissioned_service(mock_logger: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + stub_service_type = CommissionedServiceType( + TYPE_NAME="Stub Type", + NHS_UK_SERVICE_CODE="SN000", + DOS_TYPE_ID=999, + DOS_SYMPTOM_GROUP=360, + DOS_SYMPTOM_DISCRIMINATOR=9999, + DOS_SG_SD_ID="360=9999", + ) + dos_service.stub_type = True + nhs_entity.stub_type = False + changes_to_dos = ChangesToDoS( + dos_service=dos_service, + nhs_entity=nhs_entity, + service_histories=service_histories, + ) + # Act + response = compare_commissioned_service(changes=changes_to_dos, service_type=stub_service_type) + # Assert + mock_logger.assert_called_once_with( + f"{stub_service_type.TYPE_NAME} is not equal, DoS='{dos_service.stub_type}' != NHS UK='{nhs_entity.stub_type}'", + kwargs={ + f"dos_{stub_service_type.TYPE_NAME}": dos_service.stub_type, + f"nhsuk_{stub_service_type.TYPE_NAME}": nhs_entity.stub_type, + }, + ) + assert True is response + + +@patch.object(Logger, "info") +def test_compare_commissioned_service_no_change(mock_logger: MagicMock) -> None: + # Arrange + dos_service = MagicMock() + nhs_entity = MagicMock() + service_histories = MagicMock() + stub_service_type = CommissionedServiceType( + TYPE_NAME="Stub Type", + NHS_UK_SERVICE_CODE="SN000", + DOS_TYPE_ID=999, + DOS_SYMPTOM_GROUP=360, + DOS_SYMPTOM_DISCRIMINATOR=9999, + DOS_SG_SD_ID="360=9999", + ) + dos_service.stub_type = True + nhs_entity.stub_type = True + changes_to_dos = ChangesToDoS( + dos_service=dos_service, + nhs_entity=nhs_entity, + service_histories=service_histories, + ) + # Act + response = compare_commissioned_service(changes=changes_to_dos, service_type=stub_service_type) + # Assert + mock_logger.assert_called_once_with( + f"{stub_service_type.TYPE_NAME} is equal, DoS='{dos_service.stub_type}' == NHS UK='{nhs_entity.stub_type}'" + ) + assert False is response diff --git a/application/service_sync/data_processing/tests/test_formatting.py b/application/service_sync/data_processing/tests/test_formatting.py new file mode 100644 index 000000000..8abfd04ca --- /dev/null +++ b/application/service_sync/data_processing/tests/test_formatting.py @@ -0,0 +1,63 @@ +import pytest + +from application.service_sync.data_processing.formatting import format_address, format_public_phone, format_website + + +@pytest.mark.parametrize( + ("address", "formatted_address"), + [ + ("3rd Floor", "3Rd Floor"), + ("24 Hour Road", "24 Hour Road"), + ("Green Tye", "Green Tye"), + ("Much Hadham", "Much Hadham"), + ("Herts", "Herts"), + ("24 hour road", "24 Hour Road"), + ("green tye & woodsham", "Green Tye and Woodsham"), + ("much hadham", "Much Hadham"), + ("county", "County"), + ("32A unit", "32A Unit"), + ("george's road", "Georges Road"), + ("green tye", "Green Tye"), + ("less hadham", "Less Hadham"), + ("testerset", "Testerset"), + ("ABCDE", "Abcde"), + ("WOODCHURCH ROAD", "Woodchurch Road"), + ("TESTERSHIRE", "Testershire"), + ], +) +def test_format_address(address: str, formatted_address: str) -> None: + assert formatted_address == format_address(address) + + +@pytest.mark.parametrize( + ("website", "formatted_website"), + [ + ("www.test.com", "www.test.com"), + ("www.Test.com", "www.test.com"), + ("www.test.com/", "www.test.com/"), + ("www.TEST.Com", "www.test.com"), + ("www.Test.com/TEST", "www.test.com/TEST"), + ("www.rowlandspharmacy.co.uk/test?foo=test", "www.rowlandspharmacy.co.uk/test?foo=test"), + ("https://www.Test.com", "https://www.test.com"), + ("https://www.test.com/", "https://www.test.com/"), + ("https://www.TEST.Com", "https://www.test.com"), + ("https://www.Test.com/TEST", "https://www.test.com/TEST"), + ("https://www.rowlandspharmacy.co.uk/test?foo=test", "https://www.rowlandspharmacy.co.uk/test?foo=test"), + ], +) +def test_format_website(website: str, formatted_website: str) -> None: + assert formatted_website == format_website(website) + + +@pytest.mark.parametrize( + ("phone", "formatted_phone"), + [ + (" 0123456789", "0123456789"), + ("01234 56789", "0123456789"), + ("01234 56789 ", "0123456789"), + ("01 234 5678 9", "0123456789"), + ("01234 56789 ", "0123456789"), + ], +) +def test_format_public_phone(phone: str, formatted_phone: str) -> None: + assert formatted_phone == format_public_phone(phone) diff --git a/application/service_sync/data_processing/tests/test_get_data.py b/application/service_sync/data_processing/tests/test_get_data.py new file mode 100644 index 000000000..5a4f55d85 --- /dev/null +++ b/application/service_sync/data_processing/tests/test_get_data.py @@ -0,0 +1,73 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from application.service_sync.data_processing.get_data import get_dos_service_and_history + +FILE_PATH = "application.service_sync.data_processing.get_data" + + +@patch(f"{FILE_PATH}.ServiceHistories") +@patch(f"{FILE_PATH}.get_specified_opening_times_from_db") +@patch(f"{FILE_PATH}.get_standard_opening_times_from_db") +@patch(f"{FILE_PATH}.DoSService") +@patch(f"{FILE_PATH}.query_dos_db") +@patch(f"{FILE_PATH}.connect_to_db_writer") +def test_get_dos_service_and_history( + mock_connect_to_db_writer: MagicMock, + mock_query_dos_db: MagicMock, + mock_dos_service: MagicMock, + mock_get_standard_opening_times_from_db: MagicMock, + mock_get_specified_opening_times_from_db: MagicMock, + mock_service_histories: MagicMock, +) -> None: + # Arrange + service_id = 12345 + mock_query_dos_db.return_value.fetchall.return_value = [["Test"]] + # Act + dos_service, service_history = get_dos_service_and_history(service_id) + # Assert + assert mock_dos_service() == dos_service + mock_get_standard_opening_times_from_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + service_id=service_id, + ) + mock_get_specified_opening_times_from_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + service_id=service_id, + ) + assert mock_service_histories() == service_history + mock_service_histories.return_value.get_service_history_from_db.assert_called_once_with( + mock_connect_to_db_writer().__enter__(), + ) + mock_service_histories.return_value.create_service_histories_entry.assert_called_once_with() + + +@patch(f"{FILE_PATH}.query_dos_db") +@patch(f"{FILE_PATH}.connect_to_db_writer") +def test_get_dos_service_and_history_no_match( + mock_connect_to_db_writer: MagicMock, + mock_query_dos_db: MagicMock, +) -> None: + # Arrange + service_id = 12345 + mock_query_dos_db.return_value.fetchall.return_value = [] + # Act + with pytest.raises(ValueError, match=f"Service ID {service_id} not found"): + get_dos_service_and_history(service_id) + mock_connect_to_db_writer.assert_called_once() + + +@patch(f"{FILE_PATH}.query_dos_db") +@patch(f"{FILE_PATH}.connect_to_db_writer") +def test_get_dos_service_and_history_mutiple_matches( + mock_connect_to_db_writer: MagicMock, + mock_query_dos_db: MagicMock, +) -> None: + # Arrange + service_id = 12345 + mock_query_dos_db.return_value.fetchall.return_value = [["Test"], ["Test"]] + # Act + with pytest.raises(ValueError, match=f"Multiple services found for Service Id: {service_id}"): + get_dos_service_and_history(service_id) + mock_connect_to_db_writer.assert_called_once() diff --git a/application/service_sync/data_processing/tests/test_service_histories.py b/application/service_sync/data_processing/tests/test_service_histories.py new file mode 100644 index 000000000..c86574a5e --- /dev/null +++ b/application/service_sync/data_processing/tests/test_service_histories.py @@ -0,0 +1,423 @@ +from datetime import date, time +from json import dumps +from unittest.mock import MagicMock, patch + +from psycopg.rows import dict_row + +from application.common.constants import ( + DOS_SGSDID_CHANGE_KEY, + DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, +) +from application.common.opening_times import OpenPeriod, SpecifiedOpeningTime +from application.service_sync.data_processing.service_histories import ServiceHistories +from application.service_sync.data_processing.service_histories_change import ServiceHistoriesChange + +FILE_PATH = "application.service_sync.data_processing.service_histories" +SERVICE_ID = 1 + + +@patch(f"{FILE_PATH}.time") +def test_service_histories(mock_time: MagicMock) -> None: + # Arrange + mock_time.return_value = time = 123 + # Act + service_histories = ServiceHistories(service_id=SERVICE_ID) + # Assert + assert service_histories.NEW_CHANGE_KEY == "new_change" + assert service_histories.service_history == {} + assert service_histories.existing_service_history == {} + assert service_histories.service_id == SERVICE_ID + assert time == service_histories.current_epoch_time + mock_time.assert_called_once() + + +def test_service_histories_get_service_history_from_db_rows_returned() -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + mock_connection = MagicMock() + change = {"new_change": 123} + service_history_data = {"history": dumps(change)} + mock_connection.cursor.return_value.fetchall.return_value = [service_history_data] + # Act + service_history.get_service_history_from_db(mock_connection) + # Assert + assert True is service_history.history_already_exists + assert change == service_history.existing_service_history + mock_connection.cursor.assert_called_once_with(row_factory=dict_row) + mock_connection.cursor.return_value.execute.assert_called_once_with( + query="Select history from servicehistories where serviceid = %(SERVICE_ID)s", + params={"SERVICE_ID": SERVICE_ID}, + ) + mock_connection.cursor.return_value.fetchall.assert_called_once() + + +def test_service_histories_get_service_history_from_db_no_rows_returned() -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + mock_connection = MagicMock() + mock_connection.cursor.return_value.fetchall.return_value = [] + # Act + service_history.get_service_history_from_db(mock_connection) + # Assert + assert False is service_history.history_already_exists + assert service_history.existing_service_history == {} + mock_connection.cursor.assert_called_once_with(row_factory=dict_row) + mock_connection.cursor.return_value.execute.assert_called_once_with( + query="Select history from servicehistories where serviceid = %(SERVICE_ID)s", + params={"SERVICE_ID": SERVICE_ID}, + ) + + mock_connection.cursor.return_value.fetchall.assert_called_once() + + +def test_service_histories_create_service_histories_entry_no_history_already_exists() -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + # Act + service_history.create_service_histories_entry() + # Assert + assert service_history.service_history == { + service_history.NEW_CHANGE_KEY: { + "new": {}, + "initiator": {"userid": "DOS_INTEGRATION", "timestamp": "TBD"}, + "approver": {"userid": "DOS_INTEGRATION", "timestamp": "TBD"}, + }, + } + + +def test_service_histories_add_change() -> None: + # Arrange + change_key = "change_key" + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.create_service_histories_entry() + mock_service_history_change = MagicMock() + mock_service_history_change.get_change.return_value = change = "change" + # Act + service_history.add_change(change_key, mock_service_history_change) + # Assert + assert service_history.service_history == { + service_history.NEW_CHANGE_KEY: { + "new": {change_key: change}, + "initiator": {"userid": "DOS_INTEGRATION", "timestamp": "TBD"}, + "approver": {"userid": "DOS_INTEGRATION", "timestamp": "TBD"}, + }, + } + mock_service_history_change.get_change.assert_called_once_with() + + +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_service_histories_add_standard_opening_times_change(mock_service_histories_change: MagicMock) -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.add_change = mock_add_change = MagicMock() + current_opening_times = MagicMock() + current_opening_times.export_opening_times_in_seconds_for_day.return_value = current_opening_times_in_seconds = [ + "456-789", + ] + current_opening_times.export_opening_times_for_day.return_value = current_opening_times_for_day = ( + "current_opening_times_for_day" + ) + new_opening_times = MagicMock() + new_opening_times.export_opening_times_in_seconds_for_day.return_value = new_opening_times_in_seconds = ["123-456"] + weekday = "Monday" + mock_service_histories_change.return_value = mock_service_histories_change_variable = MagicMock() + # Act + service_history.add_standard_opening_times_change( + current_opening_times, + new_opening_times, + weekday, + DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + ) + # Assert + mock_add_change.assert_called_once_with( + dos_change_key=DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + change=mock_service_histories_change_variable, + ) + current_opening_times.export_opening_times_in_seconds_for_day.assert_called_once_with(weekday) + new_opening_times.export_opening_times_in_seconds_for_day.assert_called_once_with(weekday) + current_opening_times.export_opening_times_for_day.assert_called_once_with(weekday) + mock_service_histories_change.assert_called_once_with( + change_key=DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + previous_value=current_opening_times_for_day, + data={"remove": current_opening_times_in_seconds, "add": new_opening_times_in_seconds}, + ) + + +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_service_histories_add_standard_opening_times_change_no_change( + mock_service_histories_change: MagicMock, +) -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.add_change = mock_add_change = MagicMock() + current_opening_times = MagicMock() + current_opening_times.export_opening_times_in_seconds_for_day.return_value = [] + current_opening_times.export_opening_times_for_day.return_value = current_opening_times_for_day = ( + "current_opening_times_for_day" + ) + new_opening_times = MagicMock() + new_opening_times.export_opening_times_in_seconds_for_day.return_value = [] + weekday = "Monday" + mock_service_histories_change.return_value = mock_service_histories_change_variable = MagicMock() + # Act + service_history.add_standard_opening_times_change( + current_opening_times, + new_opening_times, + weekday, + DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + ) + # Assert + mock_add_change.assert_called_once_with( + dos_change_key=DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + change=mock_service_histories_change_variable, + ) + current_opening_times.export_opening_times_in_seconds_for_day.assert_called_once_with(weekday) + new_opening_times.export_opening_times_in_seconds_for_day.assert_called_once_with(weekday) + current_opening_times.export_opening_times_for_day.assert_called_once_with(weekday) + mock_service_histories_change.assert_called_once_with( + change_key=DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + previous_value=current_opening_times_for_day, + data={}, + ) + + +@patch(f"{FILE_PATH}.ServiceHistories.get_formatted_specified_opening_times") +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_service_histories_add_specified_opening_times_change_modify( + mock_service_histories_change: MagicMock, + mock_get_formatted_specified_opening_times: MagicMock, +) -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.add_change = mock_add_change = MagicMock() + current_opening_times = MagicMock() + formatted_current_opening_times = [ + "2030-12-30-closed", + "2030-12-31-1000-2000", + "2030-12-31-3000-4000", + ] + new_opening_times = MagicMock() + formatted_new_opening_times = [ + "2030-12-28-closed", + "2030-12-29-1000-2000", + "2030-12-29-3000-4000", + ] + mock_get_formatted_specified_opening_times.side_effect = [ + formatted_current_opening_times, + formatted_new_opening_times, + ] + mock_service_histories_change.return_value = mock_service_histories_change_variable = MagicMock() + # Act + service_history.add_specified_opening_times_change(current_opening_times, new_opening_times) + # Assert + mock_add_change.assert_called_once_with( + dos_change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + change=mock_service_histories_change_variable, + ) + mock_service_histories_change.assert_called_once_with( + change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + previous_value=formatted_current_opening_times, + data={"remove": formatted_current_opening_times, "add": formatted_new_opening_times}, + ) + + +@patch(f"{FILE_PATH}.ServiceHistories.get_formatted_specified_opening_times") +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_service_histories_add_specified_opening_times_change_add( + mock_service_histories_change: MagicMock, + mock_get_formatted_specified_opening_times: MagicMock, +) -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.add_change = mock_add_change = MagicMock() + current_opening_times = MagicMock() + new_opening_times = MagicMock() + formatted_new_opening_times = [ + "2030-12-28-closed", + "2030-12-29-1000-2000", + "2030-12-29-3000-4000", + ] + mock_get_formatted_specified_opening_times.side_effect = [ + [], + formatted_new_opening_times, + ] + mock_service_histories_change.return_value = mock_service_histories_change_variable = MagicMock() + # Act + service_history.add_specified_opening_times_change(current_opening_times, new_opening_times) + # Assert + mock_add_change.assert_called_once_with( + dos_change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + change=mock_service_histories_change_variable, + ) + mock_service_histories_change.assert_called_once_with( + change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + previous_value=[], + data={"add": formatted_new_opening_times}, + ) + + +@patch(f"{FILE_PATH}.ServiceHistories.get_formatted_specified_opening_times") +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_service_histories_add_specified_opening_times_change_remove( + mock_service_histories_change: MagicMock, + mock_get_formatted_specified_opening_times: MagicMock, +) -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.add_change = mock_add_change = MagicMock() + current_opening_times = MagicMock() + formatted_current_opening_times = [ + "2030-12-30-closed", + "2030-12-31-1000-2000", + "2030-12-31-3000-4000", + ] + new_opening_times = MagicMock() + mock_get_formatted_specified_opening_times.side_effect = [ + formatted_current_opening_times, + [], + ] + mock_service_histories_change.return_value = mock_service_histories_change_variable = MagicMock() + # Act + service_history.add_specified_opening_times_change(current_opening_times, new_opening_times) + # Assert + mock_add_change.assert_called_once_with( + dos_change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + change=mock_service_histories_change_variable, + ) + mock_service_histories_change.assert_called_once_with( + change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + previous_value=formatted_current_opening_times, + data={ + "remove": formatted_current_opening_times, + }, + ) + + +@patch(f"{FILE_PATH}.ServiceHistories.get_formatted_specified_opening_times") +@patch(f"{FILE_PATH}.ServiceHistoriesChange") +def test_service_histories_add_specified_opening_times_change_no_change( + mock_service_histories_change: MagicMock, + mock_get_formatted_specified_opening_times: MagicMock, +) -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.add_change = mock_add_change = MagicMock() + current_opening_times = MagicMock() + new_opening_times = MagicMock() + mock_get_formatted_specified_opening_times.side_effect = [[], []] + mock_service_histories_change.return_value = mock_service_histories_change_variable = MagicMock() + # Act + service_history.add_specified_opening_times_change(current_opening_times, new_opening_times) + # Assert + mock_add_change.assert_called_once_with( + dos_change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + change=mock_service_histories_change_variable, + ) + mock_service_histories_change.assert_called_once_with( + change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + previous_value=[], + data={}, + ) + + +def test_service_histories_add_sgsdid_change() -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.add_change = mock_add_change = MagicMock() + sgsdid = "123=456" + # Act + service_history.add_sgsdid_change(sgsdid, True) + # Assert + mock_add_change.assert_called_once_with( + dos_change_key=DOS_SGSDID_CHANGE_KEY, + change=ServiceHistoriesChange( + data={"add": ["123=456"]}, + previous_value="", + change_key="cmssgsdid", + area="clinical", + ), + ) + + +def test_service_histories_get_formatted_specified_opening_times() -> None: + # Arrange + service_history = ServiceHistories(service_id=SERVICE_ID) + open_periods = [ + OpenPeriod(time(1, 0, 0), time(2, 0, 0)), + OpenPeriod(time(3, 0, 0), time(5, 0, 0)), + OpenPeriod(time(8, 0, 0), time(12, 0, 0)), + ] + specified_opening_times = [SpecifiedOpeningTime(open_periods, date(2022, 12, 26), True)] + # Act + formatted_specified_opening_times = service_history.get_formatted_specified_opening_times( + opening_times=specified_opening_times, + ) + # Assert + assert formatted_specified_opening_times == [ + "2022-12-26-3600-7200", + "2022-12-26-10800-18000", + "2022-12-26-28800-43200", + ] + + +@patch(f"{FILE_PATH}.datetime") +@patch(f"{FILE_PATH}.query_dos_db") +def test_service_histories_save_service_histories_insert( + mock_query_dos_db: MagicMock, mock_datetime: MagicMock +) -> None: + # Arrange + mock_connection = MagicMock() + mock_datetime.now.return_value.strftime.return_value = "2022-12-26 12:00:00" + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.existing_service_history = {} + service_history.history_already_exists = False + service_history.service_history = { + service_history.NEW_CHANGE_KEY: { + "new": { + "postaladdress": { + "changetype": "modify", + "data": "52 Green Lane$Southgate", + "area": "demographic", + "previous": "51 Green Lane$Southgate", + }, + }, + "initiator": {"userid": "DOS_INTEGRATION", "timestamp": "TBD"}, + "approver": {"userid": "DOS_INTEGRATION", "timestamp": "TBD"}, + }, + } + # Act + service_history.save_service_histories(mock_connection) + # Assert + assert mock_query_dos_db.call_count == 2 + + +@patch(f"{FILE_PATH}.datetime") +@patch(f"{FILE_PATH}.query_dos_db") +def test_service_histories_save_service_histories_update( + mock_query_dos_db: MagicMock, mock_datetime: MagicMock +) -> None: + # Arrange + mock_connection = MagicMock() + mock_datetime.now.return_value.strftime.return_value = "2022-12-26 12:00:00" + service_history = ServiceHistories(service_id=SERVICE_ID) + service_history.existing_service_history = {} + service_history.history_already_exists = True + service_history.service_history = { + service_history.NEW_CHANGE_KEY: { + "new": { + "postaladdress": { + "changetype": "modify", + "data": "52 Green Lane$Southgate", + "area": "demographic", + "previous": "51 Green Lane$Southgate", + }, + }, + "initiator": {"userid": "DOS_INTEGRATION", "timestamp": "TBD"}, + "approver": {"userid": "DOS_INTEGRATION", "timestamp": "TBD"}, + }, + } + # Act + service_history.save_service_histories(mock_connection) + # Assert + assert mock_query_dos_db.call_count == 2 diff --git a/application/service_sync/data_processing/tests/test_service_histories_change.py b/application/service_sync/data_processing/tests/test_service_histories_change.py new file mode 100644 index 000000000..fc5bc8814 --- /dev/null +++ b/application/service_sync/data_processing/tests/test_service_histories_change.py @@ -0,0 +1,164 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from application.service_sync.data_processing.service_histories_change import ServiceHistoriesChange +from common.constants import ( + DOS_DEMOGRAPHICS_AREA_TYPE, + DOS_SERVICES_TABLE_CHANGE_TYPE_LIST, + DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_FRIDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SATURDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SUNDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_THURSDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_TUESDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_WEDNESDAY_CHANGE_KEY, + DOS_WEBSITE_CHANGE_KEY, +) + +FILE_PATH = "application.service_sync.data_processing.service_histories_change" +DATA = "New value to be added to db" +PREVIOUS_VALUE = "Old value to be removed from db" + + +@pytest.mark.parametrize("demographics_change_key", (DOS_SERVICES_TABLE_CHANGE_TYPE_LIST)) +@patch(f"{FILE_PATH}.ServiceHistoriesChange.get_demographics_change_action") +def test_service_histories_change_demographics_change( + mock_get_demographics_change_action: str, demographics_change_key: MagicMock +) -> None: + # Act + service_histories_change = ServiceHistoriesChange( + data=DATA, + previous_value=PREVIOUS_VALUE, + change_key=demographics_change_key, + ) + # Assert + assert service_histories_change.data == DATA + assert service_histories_change.previous_value == PREVIOUS_VALUE + assert service_histories_change.area == DOS_DEMOGRAPHICS_AREA_TYPE + mock_get_demographics_change_action.assert_called_once_with() + + +@pytest.mark.parametrize( + "opening_times_change_key", + [ + DOS_STANDARD_OPENING_TIMES_MONDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_TUESDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_WEDNESDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_THURSDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_FRIDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SATURDAY_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_SUNDAY_CHANGE_KEY, + DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + ], +) +@patch(f"{FILE_PATH}.ServiceHistoriesChange.get_opening_times_change_action") +def test_service_histories_change_opening_times_change( + mock_get_opening_times_change_action: MagicMock, opening_times_change_key: MagicMock +) -> None: + # Act + service_histories_change = ServiceHistoriesChange( + data=DATA, + previous_value=PREVIOUS_VALUE, + change_key=opening_times_change_key, + ) + # Assert + assert service_histories_change.data == DATA + assert service_histories_change.previous_value == PREVIOUS_VALUE + assert service_histories_change.area == DOS_DEMOGRAPHICS_AREA_TYPE + mock_get_opening_times_change_action.assert_called_once_with() + + +@patch(f"{FILE_PATH}.ServiceHistoriesChange.get_opening_times_change_action") +@patch(f"{FILE_PATH}.ServiceHistoriesChange.get_demographics_change_action") +def test_service_histories_change_no_change( + demographics_change_key: MagicMock, mock_get_opening_times_change_action: MagicMock +) -> None: + # Act + with pytest.raises(ValueError, match="Unknown change key"): + ServiceHistoriesChange(data=DATA, previous_value=PREVIOUS_VALUE, change_key="ANY") + # Assert + demographics_change_key.assert_not_called() + mock_get_opening_times_change_action.assert_not_called() + + +@pytest.mark.parametrize( + ("data", "previous_value", "expected_action"), + [(DATA, PREVIOUS_VALUE, "modify"), (None, PREVIOUS_VALUE, "delete"), (DATA, None, "add")], +) +def test_service_histories_change_get_demographics_change_action( + data: dict[str, str], previous_value: str | None, expected_action: str +) -> None: + # Act + service_histories_change = ServiceHistoriesChange( + data=data, + previous_value=previous_value, + change_key=DOS_WEBSITE_CHANGE_KEY, + ) # get_demographics_change_action should be called by __init__ function + # Assert + assert expected_action == service_histories_change.change_action + + +@pytest.mark.parametrize( + ("data", "expected_action"), + [ + ({"remove": "TO_REMOVE", "add": "TO_ADD"}, "modify"), + ({"remove": "TO_REMOVE"}, "delete"), + ({"add": "TO_ADD"}, "add"), + ], +) +def test_service_histories_change_get_opening_times_change_action(data: dict[str, str], expected_action: str) -> None: + # Act + service_histories_change = ServiceHistoriesChange( + data=data, + previous_value=None, + change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + ) # get_opening_times_change_action should be called by __init__ function + # Assert + assert expected_action == service_histories_change.change_action + + +def test_service_histories_change_get_opening_times_change_action_error() -> None: + # Act & Assert + with pytest.raises(ValueError, match="Unknown change action"): + ServiceHistoriesChange(data={}, previous_value=None, change_key=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY) + + +@patch(f"{FILE_PATH}.ServiceHistoriesChange.get_demographics_change_action") +def test_service_histories_change_get_change(mock_get_demographics_change_action: MagicMock) -> None: + # Arrange + mock_get_demographics_change_action.return_value = change_action = "Change Action" + service_histories_change = ServiceHistoriesChange( + data=DATA, + previous_value=PREVIOUS_VALUE, + change_key=DOS_WEBSITE_CHANGE_KEY, + ) + # Act + response = service_histories_change.get_change() + # Assert + assert response == { + "changetype": change_action, + "data": DATA, + "area": DOS_DEMOGRAPHICS_AREA_TYPE, + "previous": PREVIOUS_VALUE, + } + + +@patch(f"{FILE_PATH}.ServiceHistoriesChange.get_demographics_change_action") +def test_service_histories_change_get_change_add(mock_get_demographics_change_action: MagicMock) -> None: + # Arrange + mock_get_demographics_change_action.return_value = change_action = "add" + service_histories_change = ServiceHistoriesChange( + data=DATA, + previous_value=PREVIOUS_VALUE, + change_key=DOS_WEBSITE_CHANGE_KEY, + ) + # Act + response = service_histories_change.get_change() + # Assert + assert response == { + "changetype": change_action, + "data": DATA, + "area": DOS_DEMOGRAPHICS_AREA_TYPE, + } diff --git a/application/service_sync/data_processing/tests/test_update_dos.py b/application/service_sync/data_processing/tests/test_update_dos.py new file mode 100644 index 000000000..17321f6d5 --- /dev/null +++ b/application/service_sync/data_processing/tests/test_update_dos.py @@ -0,0 +1,589 @@ +from datetime import date, time +from unittest.mock import MagicMock, call, patch + +from application.common.constants import ( + DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR, + DOS_BLOOD_PRESSURE_SYMPTOM_GROUP, + DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR, + DOS_CONTRACEPTION_SYMPTOM_GROUP, + DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, +) +from application.common.opening_times import OpenPeriod, SpecifiedOpeningTime +from application.service_sync.data_processing.update_dos import ( + save_blood_pressure_into_db, + save_contraception_into_db, + save_demographics_into_db, + save_palliative_care_into_db, + save_specified_opening_times_into_db, + save_standard_opening_times_into_db, + update_dos_data, +) + +FILE_PATH = "application.service_sync.data_processing.update_dos" + + +@patch(f"{FILE_PATH}.log_service_updates") +@patch(f"{FILE_PATH}.save_palliative_care_into_db") +@patch(f"{FILE_PATH}.save_specified_opening_times_into_db") +@patch(f"{FILE_PATH}.save_standard_opening_times_into_db") +@patch(f"{FILE_PATH}.save_demographics_into_db") +@patch(f"{FILE_PATH}.connect_to_db_writer") +def test_update_dos_data( + mock_connect_to_db_writer: MagicMock, + mock_save_demographics_into_db: MagicMock, + mock_save_standard_opening_times_into_db: MagicMock, + mock_save_specified_opening_times_into_db: MagicMock, + mock_save_palliative_care_into_db: MagicMock, + mock_log_service_updates: MagicMock, +) -> None: + # Arrange + changes_to_dos = MagicMock() + service_histories = MagicMock() + service_id = 1 + # Act + update_dos_data(changes_to_dos, service_id, service_histories) + # Assert + mock_save_demographics_into_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + service_id=service_id, + demographics_changes=changes_to_dos.demographic_changes, + ) + mock_save_standard_opening_times_into_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + service_id=service_id, + standard_opening_times_changes=changes_to_dos.standard_opening_times_changes, + ) + mock_save_specified_opening_times_into_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + service_id=service_id, + is_changes=changes_to_dos.specified_opening_times_changes, + specified_opening_times_changes=changes_to_dos.new_specified_opening_times, + ) + mock_save_palliative_care_into_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + dos_service=changes_to_dos.dos_service, + is_changes=changes_to_dos.palliative_care_changes, + palliative_care=changes_to_dos.nhs_entity.palliative_care, + ) + service_histories.save_service_histories.assert_called_once_with(connection=mock_connect_to_db_writer().__enter__()) + mock_connect_to_db_writer.return_value.__enter__.return_value.commit.assert_called_once() + mock_connect_to_db_writer.return_value.__enter__.return_value.close.assert_called_once() + mock_log_service_updates.assert_called_once_with(changes_to_dos=changes_to_dos, service_histories=service_histories) + + +@patch(f"{FILE_PATH}.save_palliative_care_into_db") +@patch(f"{FILE_PATH}.save_specified_opening_times_into_db") +@patch(f"{FILE_PATH}.save_standard_opening_times_into_db") +@patch(f"{FILE_PATH}.save_demographics_into_db") +@patch(f"{FILE_PATH}.connect_to_db_writer") +def test_update_dos_data_no_changes( + mock_connect_to_db_writer: MagicMock, + mock_save_demographics_into_db: MagicMock, + mock_save_standard_opening_times_into_db: MagicMock, + mock_save_specified_opening_times_into_db: MagicMock, + mock_save_palliative_care_into_db: MagicMock, +) -> None: + # Arrange + changes_to_dos = MagicMock() + service_histories = MagicMock() + service_id = 1 + mock_save_demographics_into_db.return_value = False + mock_save_standard_opening_times_into_db.return_value = False + mock_save_specified_opening_times_into_db.return_value = False + mock_save_palliative_care_into_db.return_value = False + # Act + update_dos_data(changes_to_dos, service_id, service_histories) + # Assert + mock_save_demographics_into_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + service_id=service_id, + demographics_changes=changes_to_dos.demographic_changes, + ) + mock_save_standard_opening_times_into_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + service_id=service_id, + standard_opening_times_changes=changes_to_dos.standard_opening_times_changes, + ) + mock_save_specified_opening_times_into_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + service_id=service_id, + is_changes=changes_to_dos.specified_opening_times_changes, + specified_opening_times_changes=changes_to_dos.new_specified_opening_times, + ) + mock_save_palliative_care_into_db.assert_called_once_with( + connection=mock_connect_to_db_writer().__enter__(), + dos_service=changes_to_dos.dos_service, + is_changes=changes_to_dos.palliative_care_changes, + palliative_care=changes_to_dos.nhs_entity.palliative_care, + ) + service_histories.save_service_histories.assert_not_called() + mock_connect_to_db_writer.return_value.__enter__.return_value.close.assert_called_once() + + +@patch(f"{FILE_PATH}.SQL") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_demographics_into_db(mock_query_dos_db: MagicMock, mock_sql: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + service_id = 1 + demographics_changes = {"test": "test"} + mock_sql.return_value.format.return_value.as_string.return_value = query = "SELECT * FROM test" + # Act + response = save_demographics_into_db(mock_connection, service_id, demographics_changes) + # Assert + assert True is response + mock_query_dos_db.assert_called_once_with( + connection=mock_connection, + query=query, + query_vars={"SERVICE_ID": service_id}, + ) + + +def test_save_demographics_into_db_no_changes() -> None: + # Arrange + mock_connection = MagicMock() + service_id = 1 + demographics_changes = None + # Act + response = save_demographics_into_db(mock_connection, service_id, demographics_changes) + # Assert + assert False is response + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_standard_opening_times_into_db(mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + service_id = 1 + open_period = OpenPeriod(time(1, 0, 0), time(2, 0, 0)) + standard_opening_times_changes = { + 1: [open_period], + 2: [open_period], + 3: [open_period], + 4: [open_period], + 5: [open_period], + 6: [open_period], + 7: [open_period], + } + # Act + response = save_standard_opening_times_into_db(mock_connection, service_id, standard_opening_times_changes) + # Assert + assert True is response + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_specified_opening_times_into_db(mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + service_id = 1 + open_period_list = [OpenPeriod(time(1, 0, 0), time(2, 0, 0))] + specified_opening_time_list = [SpecifiedOpeningTime(open_period_list, date(2022, 12, 24), True)] + # Act + response = save_specified_opening_times_into_db(mock_connection, service_id, True, specified_opening_time_list) + # Assert + assert True is response + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_specified_opening_times_into_db_closed(mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + service_id = 1 + open_period_list = [OpenPeriod(time(1, 0, 0), time(2, 0, 0))] + specified_opening_time_list = [SpecifiedOpeningTime(open_period_list, date(2022, 12, 24), False)] + # Act + response = save_specified_opening_times_into_db(mock_connection, service_id, True, specified_opening_time_list) + # Assert + assert True is response + + +@patch(f"{FILE_PATH}.validate_z_code_exists") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_palliative_care_into_db_insert( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + palliative_care = True + mock_validate_z_code_exists.return_value = True + # Act + response = save_palliative_care_into_db(mock_connection, dos_service, True, palliative_care) + # Assert + assert True is response + mock_validate_z_code_exists.assert_called_once_with( + connection=mock_connection, + dos_service=dos_service, + symptom_group_id=DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + z_code_alias="Palliative Care", + ) + mock_query_dos_db.assert_called_once_with( + connection=mock_connection, + query="INSERT INTO servicesgsds (serviceid, sdid, sgid) VALUES (%(SERVICE_ID)s, %(SDID)s, %(SGID)s);", + query_vars={"SERVICE_ID": dos_service.id, "SDID": 14167, "SGID": 360}, + ) + + +@patch(f"{FILE_PATH}.validate_z_code_exists") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_palliative_care_into_db_delete( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + palliative_care = False + mock_validate_z_code_exists.return_value = True + # Act + response = save_palliative_care_into_db(mock_connection, dos_service, True, palliative_care) + # Assert + assert True is response + mock_validate_z_code_exists.assert_called_once_with( + connection=mock_connection, + dos_service=dos_service, + symptom_group_id=DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + z_code_alias="Palliative Care", + ) + mock_query_dos_db.assert_called_once_with( + connection=mock_connection, + query="DELETE FROM servicesgsds WHERE serviceid=%(SERVICE_ID)s AND sdid=%(SDID)s AND sgid=%(SGID)s;", + query_vars={"SERVICE_ID": dos_service.id, "SDID": 14167, "SGID": 360}, + ) + + +@patch(f"{FILE_PATH}.validate_z_code_exists") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_palliative_care_into_db_no_z_code( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + palliative_care = True + mock_validate_z_code_exists.return_value = False + # Act + response = save_palliative_care_into_db(mock_connection, dos_service, True, palliative_care) + # Assert + assert False is response + mock_validate_z_code_exists.assert_called_once_with( + connection=mock_connection, + dos_service=dos_service, + symptom_group_id=DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + z_code_alias="Palliative Care", + ) + mock_query_dos_db.assert_not_called() + + +@patch(f"{FILE_PATH}.validate_z_code_exists") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_palliative_care_into_db_no_changes( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + palliative_care = True + # Act + response = save_palliative_care_into_db(mock_connection, dos_service, False, palliative_care) + # Assert + assert False is response + mock_validate_z_code_exists.assert_not_called() + mock_query_dos_db.assert_not_called() + + +@patch(f"{FILE_PATH}.validate_z_code_exists_on_service") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_blood_pressure_into_db_insert( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists_on_service: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 148 + blood_pressure = True + service_histories = MagicMock() + mock_validate_z_code_exists_on_service.return_value = True + # Act + response = save_blood_pressure_into_db(mock_connection, dos_service, True, blood_pressure, service_histories) + # Assert + assert response == (True, service_histories) + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query="UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;", + query_vars={"STATUS_ID": 1, "SERVICE_ID": dos_service.id}, + ), + call().close(), + ], + ) + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_blood_pressure_into_db_delete(mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 148 + blood_pressure = False + service_histories = MagicMock() + # Act + response = save_blood_pressure_into_db(mock_connection, dos_service, True, blood_pressure, service_histories) + # Assert + assert response == (True, service_histories) + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query="UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;", + query_vars={"STATUS_ID": 2, "SERVICE_ID": dos_service.id}, + ), + call().close(), + ], + ) + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_blood_pressure_into_db_no_changes(mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 148 + blood_pressure = False + service_histories = MagicMock() + # Act + response = save_blood_pressure_into_db(mock_connection, dos_service, False, blood_pressure, service_histories) + # Assert + assert response == (False, service_histories) + mock_query_dos_db.assert_not_called() + + +@patch(f"{FILE_PATH}.save_sgsdid_update") +@patch(f"{FILE_PATH}.validate_z_code_exists") +@patch(f"{FILE_PATH}.validate_z_code_exists_on_service") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_blood_pressure_into_db_z_code_not_exist( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists_on_service: MagicMock, + mock_validate_z_code_exists: MagicMock, + mock_save_sgsdid_update: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 148 + blood_pressure = True + service_histories = MagicMock() + mock_validate_z_code_exists_on_service.return_value = False + mock_validate_z_code_exists.return_value = True + # Act + response = save_blood_pressure_into_db(mock_connection, dos_service, True, blood_pressure, service_histories) + # Assert + assert response == (True, service_histories) + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query="UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;", + query_vars={"STATUS_ID": 1, "SERVICE_ID": dos_service.id}, + ), + call().close(), + ], + ) + mock_save_sgsdid_update.assert_called_once_with( + name="blood pressure", + value=blood_pressure, + sdid=DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR, + sgid=DOS_BLOOD_PRESSURE_SYMPTOM_GROUP, + dos_service=dos_service, + connection=mock_connection, + ) + + +@patch(f"{FILE_PATH}.save_sgsdid_update") +@patch(f"{FILE_PATH}.validate_z_code_exists") +@patch(f"{FILE_PATH}.validate_z_code_exists_on_service") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_blood_pressure_into_db_z_not_valid( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists_on_service: MagicMock, + mock_validate_z_code_exists: MagicMock, + mock_save_sgsdid_update: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 148 + blood_pressure = True + service_histories = MagicMock() + mock_validate_z_code_exists_on_service.return_value = False + mock_validate_z_code_exists.return_value = False + # Act + response = save_blood_pressure_into_db(mock_connection, dos_service, True, blood_pressure, service_histories) + # Assert + assert response == (False, service_histories) + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query="UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;", + query_vars={"STATUS_ID": 1, "SERVICE_ID": dos_service.id}, + ), + call().close(), + ], + ) + mock_save_sgsdid_update.assert_not_called() + + +@patch(f"{FILE_PATH}.validate_z_code_exists_on_service") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_contraception_into_db_insert( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists_on_service: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 149 + contraception = True + service_histories = MagicMock() + mock_validate_z_code_exists_on_service.return_value = True + # Act + response = save_contraception_into_db(mock_connection, dos_service, True, contraception, service_histories) + # Assert + assert response == (True, service_histories) + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query="UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;", + query_vars={"STATUS_ID": 1, "SERVICE_ID": dos_service.id}, + ), + call().close(), + ], + ) + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_contraception_into_db_delete(mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 149 + contraception = False + service_histories = MagicMock() + # Act + response = save_contraception_into_db(mock_connection, dos_service, True, contraception, service_histories) + # Assert + assert response == (True, service_histories) + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query="UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;", + query_vars={"STATUS_ID": 2, "SERVICE_ID": dos_service.id}, + ), + call().close(), + ], + ) + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_contraception_into_db_no_changes(mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 149 + contraception = False + service_histories = MagicMock() + # Act + response = save_contraception_into_db(mock_connection, dos_service, False, contraception, service_histories) + # Assert + assert response == (False, service_histories) + mock_query_dos_db.assert_not_called() + + +@patch(f"{FILE_PATH}.save_sgsdid_update") +@patch(f"{FILE_PATH}.validate_z_code_exists") +@patch(f"{FILE_PATH}.validate_z_code_exists_on_service") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_contraception_into_db_z_code_not_exist( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists_on_service: MagicMock, + mock_validate_z_code_exists: MagicMock, + mock_save_sgsdid_update: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 149 + contraception = True + service_histories = MagicMock() + mock_validate_z_code_exists_on_service.return_value = False + mock_validate_z_code_exists.return_value = True + # Act + response = save_contraception_into_db(mock_connection, dos_service, True, contraception, service_histories) + # Assert + assert response == (True, service_histories) + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query="UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;", + query_vars={"STATUS_ID": 1, "SERVICE_ID": dos_service.id}, + ), + call().close(), + ], + ) + mock_save_sgsdid_update.assert_called_once_with( + name="contraception", + value=contraception, + sdid=DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR, + sgid=DOS_CONTRACEPTION_SYMPTOM_GROUP, + dos_service=dos_service, + connection=mock_connection, + ) + + +@patch(f"{FILE_PATH}.save_sgsdid_update") +@patch(f"{FILE_PATH}.validate_z_code_exists") +@patch(f"{FILE_PATH}.validate_z_code_exists_on_service") +@patch(f"{FILE_PATH}.query_dos_db") +def test_save_contraception_into_db_z_not_valid( + mock_query_dos_db: MagicMock, + mock_validate_z_code_exists_on_service: MagicMock, + mock_validate_z_code_exists: MagicMock, + mock_save_sgsdid_update: MagicMock, +) -> None: + # Arrange + mock_connection = MagicMock() + dos_service = MagicMock() + dos_service.typeid = 149 + contraception = True + service_histories = MagicMock() + mock_validate_z_code_exists_on_service.return_value = False + mock_validate_z_code_exists.return_value = False + # Act + response = save_contraception_into_db(mock_connection, dos_service, True, contraception, service_histories) + # Assert + assert response == (False, service_histories) + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query="UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;", + query_vars={"STATUS_ID": 1, "SERVICE_ID": dos_service.id}, + ), + call().close(), + ], + ) + mock_save_sgsdid_update.assert_not_called() diff --git a/application/service_sync/data_processing/tests/test_validation.py b/application/service_sync/data_processing/tests/test_validation.py new file mode 100644 index 000000000..d99858b10 --- /dev/null +++ b/application/service_sync/data_processing/tests/test_validation.py @@ -0,0 +1,152 @@ +from unittest.mock import MagicMock, call, patch + +import pytest +from aws_lambda_powertools.logging import Logger + +from application.common.constants import DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, DOS_PALLIATIVE_CARE_SYMPTOM_GROUP +from application.common.nhs import NHSEntity +from application.service_sync.data_processing.validation import ( + validate_opening_times, + validate_website, + validate_z_code_exists, +) + +FILE_PATH = "application.service_sync.data_processing.validation" + + +@patch(f"{FILE_PATH}.log_service_with_generic_bank_holiday") +@patch.object(Logger, "warning") +def test_validate_opening_times_sucessful( + mock_warning_logger: MagicMock, + mock_log_service_with_generic_bank_holiday: MagicMock, +) -> None: + # Arrange + nhs_entity = MagicMock() + nhs_entity.odscode.return_value = "12345" + nhs_entity.all_times_valid.return_value = True + dos_service = MagicMock() + dos_service.any_generic_bankholiday_open_periods.return_value = False + # Act + result = validate_opening_times(dos_service, nhs_entity) + # Assert + assert result is True + mock_warning_logger.assert_not_called() + mock_log_service_with_generic_bank_holiday.assert_not_called() + + +@patch(f"{FILE_PATH}.log_service_with_generic_bank_holiday") +@patch.object(Logger, "warning") +def test_validate_opening_times_failure( + mock_warning_logger: MagicMock, + mock_log_service_with_generic_bank_holiday: MagicMock, +) -> None: + # Arrange + nhs_entity = MagicMock() + nhs_entity.odscode.return_value = "12345" + nhs_entity.all_times_valid.return_value = False + dos_service = MagicMock() + dos_service.any_generic_bankholiday_open_periods.return_value = True + # Act + result = validate_opening_times(dos_service, nhs_entity) + # Assert + assert result is False + mock_warning_logger.assert_called_once_with( + f"Opening Times for NHS Entity '{nhs_entity.odscode}' were previously found " + "to be invalid or illogical. Skipping change.", + ) + mock_log_service_with_generic_bank_holiday.assert_called_once_with(nhs_entity, dos_service) + + +@pytest.mark.parametrize( + "website", + [ + "www.test.com", + "www.test.com/TEST", + "www.rowlandspharmacy.co.uk/test?foo=test", + "https://www.rowlandspharmacy.co.uk/test?foo=test", + ], +) +@patch(f"{FILE_PATH}.log_website_is_invalid") +def test_validate_website_sucess(mock_log_website_is_invalid: MagicMock, website: str) -> None: + # Act & Assert + assert True is validate_website(nhs_entity=NHSEntity({}), nhs_website=website, dos_service=MagicMock()) + mock_log_website_is_invalid.assert_not_called() + + +@pytest.mark.parametrize( + "website", + [ + "https://testpharmacy@gmail.com", + "test@test.com", + ], +) +@patch(f"{FILE_PATH}.log_website_is_invalid") +def test_validate_website_failure(mock_log_website_is_invalid: MagicMock, website: str) -> None: + # Arrange + nhs_entity = NHSEntity({}) + dos_service = MagicMock() + # Act & Assert + assert False is validate_website(nhs_entity=nhs_entity, nhs_website=website, dos_service=dos_service) + mock_log_website_is_invalid.assert_called_once_with(nhs_entity, website, dos_service) + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_validate_z_code_exists(mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + mock_query_dos_db.return_value.rowcount = 1 + dos_service = MagicMock() + # Act + response = validate_z_code_exists( + connection=mock_connection, + dos_service=dos_service, + symptom_group_id=DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + z_code_alias="Palliative Care", + ) + # Assert + assert True is response + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query=( + "SELECT id FROM symptomgroupsymptomdiscriminators WHERE symptomgroupid=%(SGID)s " + "AND symptomdiscriminatorid=%(SDID)s;" + ), + query_vars={"SGID": 360, "SDID": 14167}, + ), + call().close(), + ], + ) + + +@patch(f"{FILE_PATH}.query_dos_db") +def test_validate_z_code_existss_does_not_exist(mock_query_dos_db: MagicMock) -> None: + # Arrange + mock_connection = MagicMock() + mock_query_dos_db.return_value.rowcount = 0 + dos_service = MagicMock() + # Act + response = validate_z_code_exists( + connection=mock_connection, + dos_service=dos_service, + symptom_group_id=DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + z_code_alias="Palliative Care", + ) + # Assert + assert False is response + mock_query_dos_db.assert_has_calls( + calls=[ + call( + connection=mock_connection, + query=( + "SELECT id FROM symptomgroupsymptomdiscriminators WHERE symptomgroupid=%(SGID)s " + "AND symptomdiscriminatorid=%(SDID)s;" + ), + query_vars={"SGID": 360, "SDID": 14167}, + ), + call().close(), + ], + ) diff --git a/application/service_sync/data_processing/update_dos.py b/application/service_sync/data_processing/update_dos.py new file mode 100644 index 000000000..f7f2a6b0d --- /dev/null +++ b/application/service_sync/data_processing/update_dos.py @@ -0,0 +1,544 @@ +from aws_lambda_powertools.logging import Logger +from psycopg import Connection +from psycopg.sql import SQL, Identifier, Literal + +from ..service_update_logger import log_service_updates +from .changes_to_dos import ChangesToDoS +from .service_histories import ServiceHistories +from .validation import validate_z_code_exists, validate_z_code_exists_on_service +from common.constants import ( + DOS_ACTIVE_STATUS_ID, + DOS_BLOOD_PRESSURE_SGSDID, + DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR, + DOS_BLOOD_PRESSURE_SYMPTOM_GROUP, + DOS_BLOOD_PRESSURE_TYPE_ID, + DOS_CLOSED_STATUS_ID, + DOS_CONTRACEPTION_SGSDID, + DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR, + DOS_CONTRACEPTION_SYMPTOM_GROUP, + DOS_CONTRACEPTION_TYPE_ID, + DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, +) +from common.dos import DoSService +from common.dos_db_connection import connect_to_db_writer, query_dos_db +from common.opening_times import OpenPeriod, SpecifiedOpeningTime + +logger = Logger(child=True) + + +def update_dos_data(changes_to_dos: ChangesToDoS, service_id: int, service_histories: ServiceHistories) -> None: + """Updates the DoS database with the changes to the service. + + Args: + changes_to_dos (ChangesToDoS): Changes to the dos service + service_id (int): Id of service to update + service_histories (ServiceHistories): Service history of the service + """ + connection = None + try: + # Save all the changes to the DoS database with a single transaction + with connect_to_db_writer() as connection: + is_demographic_changes = save_demographics_into_db( + connection=connection, + service_id=service_id, + demographics_changes=changes_to_dos.demographic_changes, + ) + is_standard_opening_times_changes = save_standard_opening_times_into_db( + connection=connection, + service_id=service_id, + standard_opening_times_changes=changes_to_dos.standard_opening_times_changes, + ) + is_specified_opening_times_changes = save_specified_opening_times_into_db( + connection=connection, + service_id=service_id, + is_changes=changes_to_dos.specified_opening_times_changes, + specified_opening_times_changes=changes_to_dos.new_specified_opening_times, + ) + is_palliative_care_changes = save_palliative_care_into_db( + connection=connection, + dos_service=changes_to_dos.dos_service, + is_changes=changes_to_dos.palliative_care_changes, + palliative_care=changes_to_dos.nhs_entity.palliative_care, + ) + is_blood_pressure_changes, service_histories = save_blood_pressure_into_db( + connection=connection, + dos_service=changes_to_dos.dos_service, + is_changes=changes_to_dos.blood_pressure_changes, + blood_pressure=changes_to_dos.nhs_entity.blood_pressure, + service_histories=service_histories, + ) + is_contraception_changes, service_histories = save_contraception_into_db( + connection=connection, + dos_service=changes_to_dos.dos_service, + is_changes=changes_to_dos.contraception_changes, + contraception=changes_to_dos.nhs_entity.contraception, + service_histories=service_histories, + ) + # If there are any changes, update the service history and commit the changes to the database + if any( + [ + is_demographic_changes, + is_standard_opening_times_changes, + is_specified_opening_times_changes, + is_palliative_care_changes, + is_blood_pressure_changes, + is_contraception_changes, + ], + ): + service_histories.save_service_histories(connection=connection) + connection.commit() + logger.info(f"Updates successfully committed to the DoS database for service id {service_id}") + log_service_updates(changes_to_dos=changes_to_dos, service_histories=service_histories) + else: + logger.info(f"No changes to save for service id {service_id}") + finally: + # Close the connection even if an error occurs + if connection: + # Close without committing causes the transaction to be rolled back + connection.close() + + +def save_demographics_into_db(connection: Connection, service_id: int, demographics_changes: dict) -> bool: + """Saves the demographic changes to the DoS database. + + Args: + connection (connection): Connection to the DoS database + service_id (int): Id of service to update + demographics_changes (dict): Demographic changes to save + + Returns: + bool: True if any demographic changes were saved, False otherwise + """ + if demographics_changes: + # Update the service demographics + logger.info( + f"Demographics changes found for service id {service_id}", + demographics_changes=demographics_changes, + ) + columns_and_values = [ + SQL("{} = {}").format(Identifier(key), Literal(value)).as_string(connection) + for key, value in demographics_changes.items() + ] + query = SQL("""UPDATE services SET {} WHERE id = %(SERVICE_ID)s;""").format(SQL(", ".join(columns_and_values))) + query_str = query.as_string(connection) + cursor = query_dos_db( + connection=connection, + query=query_str, + query_vars={"SERVICE_ID": service_id}, + ) + cursor.close() + return True + + # No demographic changes found so no need to update the service + logger.info(f"No demographic changes found for service id {service_id}") + return False + + +def save_standard_opening_times_into_db( + connection: Connection, + service_id: int, + standard_opening_times_changes: dict[int, list[OpenPeriod]], +) -> bool: + """Saves the standard opening times changes to the DoS database. + + Args: + connection (connection): Connection to the DoS database + service_id (int): Id of the service to update + standard_opening_times_changes (Dict[int, List[OpenPeriod]]): Changes to the standard opening times + + Returns: + bool: True if changes were made to the database, False if no changes were made + """ + if standard_opening_times_changes: + logger.info(f"Saving standard opening times changes for service id {service_id}") + for dayid, opening_periods in standard_opening_times_changes.items(): + logger.info(f"Deleting standard opening times for dayid: {dayid}") + # Cascade delete the standard opening times in both + # servicedayopenings table and servicedayopeningtimes table + cursor = query_dos_db( + connection=connection, + query="""DELETE FROM servicedayopenings WHERE serviceid=%(SERVICE_ID)s AND dayid=%(DAY_ID)s""", + query_vars={"SERVICE_ID": service_id, "DAY_ID": dayid}, + ) + cursor.close() + if opening_periods != []: + logger.info(f"Saving standard opening times for dayid: {dayid}") + cursor = query_dos_db( + connection=connection, + query=( + """INSERT INTO servicedayopenings (serviceid, dayid) """ + """VALUES (%(SERVICE_ID)s, %(DAY_ID)s) RETURNING id""" + ), + query_vars={"SERVICE_ID": service_id, "DAY_ID": dayid}, + ) + # Get the id of the newly created servicedayopenings entry by using the RETURNING clause + service_day_opening_id = cursor.fetchone()["id"] + cursor.close() + + open_period: OpenPeriod # Type hint for the for loop + for open_period in opening_periods: + logger.info(f"Saving standard opening times period for dayid: {dayid}, period: {open_period}") + cursor = query_dos_db( + connection=connection, + query=( + """INSERT INTO servicedayopeningtimes (servicedayopeningid, starttime, endtime) """ + """VALUES (%(SERVICE_DAY_OPENING_ID)s, %(OPEN_PERIOD_START)s, %(OPEN_PERIOD_END)s);""" + ), + query_vars={ + "SERVICE_DAY_OPENING_ID": service_day_opening_id, + "OPEN_PERIOD_START": open_period.start, + "OPEN_PERIOD_END": open_period.end, + }, + ) + cursor.close() + else: + logger.info(f"No standard opening times to add for dayid: {dayid}") + return True + logger.info(f"No standard opening times changes to save for service id {service_id}") + return False + + +def save_specified_opening_times_into_db( + connection: Connection, + service_id: int, + is_changes: bool, + specified_opening_times_changes: list[SpecifiedOpeningTime], +) -> bool: + """Saves the specified opening times changes to the DoS database. + + Args: + connection (connection): Connection to the DoS database + service_id (int): Id of the service to update + is_changes (bool): True if changes should be made to the database, False if no changes need to be made + specified_opening_times_changes (List[SpecifiedOpeningTime]): Changes to the specified opening times + + Returns: + bool: True if changes were made to the database, False if no changes were made + """ + if is_changes: + logger.info(f"Deleting all specified opening times for service id {service_id}") + # Cascade delete the standard opening times in both + # servicedayopenings table and servicedayopeningtimes table + cursor = query_dos_db( + connection=connection, + query=("""DELETE FROM servicespecifiedopeningdates WHERE serviceid=%(SERVICE_ID)s """), + query_vars={"SERVICE_ID": service_id}, + ) + cursor.close() + for specified_opening_times_day in specified_opening_times_changes: + logger.info(f"Saving specfied opening times for: {specified_opening_times_day}") + cursor = query_dos_db( + connection=connection, + query=( + """INSERT INTO servicespecifiedopeningdates (date,serviceid) """ + """VALUES (%(SPECIFIED_OPENING_TIMES_DATE)s,%(SERVICE_ID)s) RETURNING id;""" + ), + query_vars={"SPECIFIED_OPENING_TIMES_DATE": specified_opening_times_day.date, "SERVICE_ID": service_id}, + ) + # Get the id of the newly created servicedayopenings entry by using the RETURNING clause + service_specified_opening_date_id = cursor.fetchone()["id"] + cursor.close() + if specified_opening_times_day.is_open: + # If the day is open, save the potentially mutiple opening times + open_period: OpenPeriod # Type hint for the for loop + for open_period in specified_opening_times_day.open_periods: + logger.debug( + "Saving standard opening times period for dayid: " + f"{specified_opening_times_day.date}, period: {open_period}", + ) + cursor = query_dos_db( + connection=connection, + query=( + """INSERT INTO servicespecifiedopeningtimes """ + """(starttime, endtime, isclosed, servicespecifiedopeningdateid) """ + """VALUES (%(OPEN_PERIOD_START)s, %(OPEN_PERIOD_END)s,""" + """%(IS_CLOSED)s,%(SERVICE_SPECIFIED_OPENING_DATE_ID)s);""" + ), + query_vars={ + "OPEN_PERIOD_START": open_period.start, + "OPEN_PERIOD_END": open_period.end, + "IS_CLOSED": not specified_opening_times_day.is_open, + "SERVICE_SPECIFIED_OPENING_DATE_ID": service_specified_opening_date_id, + }, + ) + cursor.close() + else: + # If the day is closed, save the single closed all day times + cursor = query_dos_db( + connection=connection, + query=( + """INSERT INTO servicespecifiedopeningtimes """ + """(starttime, endtime, isclosed, servicespecifiedopeningdateid) """ + """VALUES ('00:00:00', '00:00:00',""" + """%(IS_CLOSED)s,%(SERVICE_SPECIFIED_OPENING_DATE_ID)s);""" + ), + query_vars={ + "IS_CLOSED": not specified_opening_times_day.is_open, + "SERVICE_SPECIFIED_OPENING_DATE_ID": service_specified_opening_date_id, + }, + ) + cursor.close() + + return True + logger.info(f"No specified opening times changes to save for service id {service_id}") + return False + + +def save_sgsdid_update( + name: str, + value: bool, + sdid: int, + sgid: int, + dos_service: DoSService, + connection: Connection, +) -> None: + """Saves the palliative care update to the DoS database. + + Args: + name (str): The name of the change + value (bool): True if the change is to set the value to be added, False if the change is to remove the value + sdid (int): The symptom discriminator id + sgid (int): The symptom group id + dos_service (DoSService): The dos service to update + connection (Connection): Connection to the DoS database + + Returns: + None + """ + query_vars = { + "SERVICE_ID": dos_service.id, + "SDID": sdid, + "SGID": sgid, + } + + if value: + query = "INSERT INTO servicesgsds (serviceid, sdid, sgid) VALUES (%(SERVICE_ID)s, %(SDID)s, %(SGID)s);" + logger.debug(f"Setting {name} to true for service id {dos_service.id}") + else: + query = "DELETE FROM servicesgsds WHERE serviceid=%(SERVICE_ID)s AND sdid=%(SDID)s AND sgid=%(SGID)s;" + logger.debug(f"Setting {name} to false for service id {dos_service.id}") + cursor = query_dos_db(connection=connection, query=query, query_vars=query_vars) + cursor.close() + logger.info(f"Saving {name} changes for service id {dos_service.id}", value=value) + + +def save_palliative_care_into_db( + connection: Connection, + dos_service: DoSService, + is_changes: bool, + palliative_care: bool, +) -> bool: + """Saves the palliative care changes to the DoS database. + + Args: + connection (connection): Connection to the DoS database + dos_service (DoSService): The dos service to update + is_changes (bool): True if changes should be made to the database, False if no changes need to be made + palliative_care (bool): Set palliative care in db to true or false + + Returns: + bool: True if changes were made to the database, False if no changes were made + """ + # If no changes, return false + if not is_changes: + logger.info( + f"No palliative care changes to save for service id {dos_service.id}", + palliative_care_is_set_to=palliative_care, + ) + return False + + if validate_z_code_exists( + connection=connection, + dos_service=dos_service, + symptom_group_id=DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + z_code_alias="Palliative Care", + ): + save_sgsdid_update( + name="palliative care", + value=palliative_care, + sdid=DOS_PALLIATIVE_CARE_SYMPTOM_DISCRIMINATOR, + sgid=DOS_PALLIATIVE_CARE_SYMPTOM_GROUP, + dos_service=dos_service, + connection=connection, + ) + return True + + logger.error( + f"Unable to save palliative care changes for service id {dos_service.id} as the " + "palliative care Z code does not exist in the DoS database", + palliative_care_is_set_to=palliative_care, + cloudwatch_metric_filter_matching_attribute="DoSPalliativeCareZCodeDoesNotExist", + ) + return False + + +def save_blood_pressure_into_db( + connection: Connection, + dos_service: DoSService, + is_changes: bool, + blood_pressure: bool, + service_histories: ServiceHistories, +) -> tuple[bool, ServiceHistories]: + """Saves the blood pressure changes to the DoS database. + + Args: + connection (connection): Connection to the DoS database + dos_service (DoSService): The dos service to update + is_changes (bool): True if changes should be made to the database, False if no changes need to be made + blood_pressure (bool): Set blood pressure in db to true or false + service_histories (ServiceHistories): Service history of the service + + Returns: + bool: True if changes were made to the database, False if no changes were made + service_histories (ServiceHistories): Service history of the service + """ + + def save_service_status_update() -> None: + status = DOS_ACTIVE_STATUS_ID if blood_pressure else DOS_CLOSED_STATUS_ID + query = "UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;" + cursor = query_dos_db( + connection=connection, + query=query, + query_vars={"STATUS_ID": status, "SERVICE_ID": dos_service.id}, + ) + cursor.close() + + if dos_service.typeid != DOS_BLOOD_PRESSURE_TYPE_ID: + logger.info( + f"No blood pressure changes to save for service id {dos_service.id} as the " + "service is not a blood pressure service", + current_blood_pressure=blood_pressure, + ) + return False, service_histories + # If no changes, return false + if not is_changes: + logger.info( + f"No blood pressure changes to save for service id {dos_service.id}", + current_blood_pressure=blood_pressure, + ) + return False, service_histories + + save_service_status_update() + if blood_pressure and not validate_z_code_exists_on_service( + connection=connection, + dos_service=dos_service, + symptom_group_id=DOS_BLOOD_PRESSURE_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR, + z_code_alias="Blood Pressure", + ): + if not validate_z_code_exists( + connection=connection, + dos_service=dos_service, + symptom_group_id=DOS_BLOOD_PRESSURE_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR, + z_code_alias="Blood Pressure", + ): + logger.error( + f"Unable to save z code blood pressure changes for service id {dos_service.id} as the " + "blood pressure Z code does not exist in the DoS database", + new_blood_pressure_status=blood_pressure, + cloudwatch_metric_filter_matching_attribute="BloodPressureZCodeDoesNotExist", + ) + return False, service_histories + + save_sgsdid_update( + name="blood pressure", + value=blood_pressure, + sdid=DOS_BLOOD_PRESSURE_SYMPTOM_DISCRIMINATOR, + sgid=DOS_BLOOD_PRESSURE_SYMPTOM_GROUP, + dos_service=dos_service, + connection=connection, + ) + service_histories.add_sgsdid_change( + sgsdid=DOS_BLOOD_PRESSURE_SGSDID, + new_value=blood_pressure, + ) + + return True, service_histories + + +def save_contraception_into_db( + connection: Connection, + dos_service: DoSService, + is_changes: bool, + contraception: bool, + service_histories: ServiceHistories, +) -> tuple[bool, ServiceHistories]: + """Saves the contraception changes to the DoS database. + + Args: + connection (connection): Connection to the DoS database + dos_service (DoSService): The dos service to update + is_changes (bool): True if changes should be made to the database, False if no changes need to be made + contraception (bool): Set contraception in db to true or false + service_histories (ServiceHistories): Service history of the service + + Returns: + bool: True if changes were made to the database, False if no changes were made + service_histories (ServiceHistories): Service history of the service + """ + + def save_service_status_update() -> None: + status = DOS_ACTIVE_STATUS_ID if contraception else DOS_CLOSED_STATUS_ID + query = "UPDATE services SET statusid=%(STATUS_ID)s WHERE id=%(SERVICE_ID)s;" + cursor = query_dos_db( + connection=connection, + query=query, + query_vars={"STATUS_ID": status, "SERVICE_ID": dos_service.id}, + ) + cursor.close() + + if dos_service.typeid != DOS_CONTRACEPTION_TYPE_ID: + logger.info( + f"No contraception changes to save for service id {dos_service.id} as the " + "service is not a contraception service", + current_contraception=contraception, + ) + return False, service_histories + # If no changes, return false + if not is_changes: + logger.info( + f"No contraception changes to save for service id {dos_service.id}", + current_contraception=contraception, + ) + return False, service_histories + + save_service_status_update() + if contraception and not validate_z_code_exists_on_service( + connection=connection, + dos_service=dos_service, + symptom_group_id=DOS_CONTRACEPTION_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR, + z_code_alias="Contraception", + ): + if not validate_z_code_exists( + connection=connection, + dos_service=dos_service, + symptom_group_id=DOS_CONTRACEPTION_SYMPTOM_GROUP, + symptom_discriminator_id=DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR, + z_code_alias="Contraception", + ): + logger.error( + f"Unable to save z code contraception changes for service id {dos_service.id} as the " + "contraception Z code does not exist in the DoS database", + new_contraception_status=contraception, + cloudwatch_metric_filter_matching_attribute="ContraceptionZCodeDoesNotExist", + ) + return False, service_histories + + save_sgsdid_update( + name="contraception", + value=contraception, + sdid=DOS_CONTRACEPTION_SYMPTOM_DISCRIMINATOR, + sgid=DOS_CONTRACEPTION_SYMPTOM_GROUP, + dos_service=dos_service, + connection=connection, + ) + service_histories.add_sgsdid_change( + sgsdid=DOS_CONTRACEPTION_SGSDID, + new_value=contraception, + ) + + return True, service_histories diff --git a/application/service_sync/data_processing/validation.py b/application/service_sync/data_processing/validation.py new file mode 100644 index 000000000..21ecff6e7 --- /dev/null +++ b/application/service_sync/data_processing/validation.py @@ -0,0 +1,136 @@ +from re import search + +from aws_lambda_powertools.logging import Logger +from psycopg import Connection + +from ..reporting import ( + log_generic_change_event_error, + log_service_with_generic_bank_holiday, + log_website_is_invalid, +) +from common.dos import DoSService +from common.dos_db_connection import query_dos_db +from common.nhs import NHSEntity + +logger = Logger(child=True) + + +def validate_opening_times(dos_service: DoSService, nhs_entity: NHSEntity) -> bool: + """Validates the opening times match DoS validation rules. + + Args: + dos_service (DoSService): DoS service object to validate. + nhs_entity (NHSEntity): NHS entity object to log if validation warning. + + Returns: + bool: True if opening times match DoS validation rules, False otherwise. + """ + if dos_service.any_generic_bankholiday_open_periods(): + log_service_with_generic_bank_holiday(nhs_entity, dos_service) + if not nhs_entity.all_times_valid(): + logger.warning( + f"Opening Times for NHS Entity '{nhs_entity.odscode}' " + "were previously found to be invalid or illogical. Skipping change.", + ) + return False + return True + + +def validate_website(nhs_entity: NHSEntity, nhs_website: str, dos_service: DoSService) -> bool: + """Validates the website matches DoS validation rules. + + Args: + nhs_entity (NHSEntity): NHS entity object to log if validation warning. + nhs_website (str): NHS website to validate. + dos_service (DoSService): DoS service object to validate. + + Returns: + bool: True if website matches DoS validation rules, False otherwise. + """ + if search(r"^(https?:\/\/)?([a-z\d][a-z\d-]*[a-z\d]\.)+[a-z]{2,}(\/.*)?$", nhs_website): + return True + log_website_is_invalid(nhs_entity, nhs_website, dos_service) + return False + + +def validate_z_code_exists_on_service( + connection: Connection, + dos_service: DoSService, + symptom_group_id: int, + symptom_discriminator_id: int, + z_code_alias: str, +) -> bool: + """Validate if the z code exists on the service in the DoS database. + + Args: + connection (connection): Connection to the DoS database + dos_service (DoSService): The dos service to update + symptom_group_id (int): The symptom group + symptom_discriminator_id (int): The symptom discriminators + z_code_alias (str): The z code alias for logging purposes + + Returns: + bool: True if the Z code exists on service, False if it does not + """ + cursor = query_dos_db( + connection=connection, + query=("SELECT id FROM servicesgsds WHERE serviceid=%(SERVICE_ID)s AND sgid=%(SGID)s AND sdid=%(SDID)s;"), + query_vars={ + "SERVICE_ID": dos_service.id, + "SGID": symptom_group_id, + "SDID": symptom_discriminator_id, + }, + ) + service_sgsd_rowcount = cursor.rowcount + cursor.close() + + if service_sgsd_rowcount == 1: + logger.info(f"{z_code_alias} Service's Z code exists in the DoS database") + return True + + logger.info(f"{z_code_alias} Service's Z code does not exist in the DoS database") + return False + + +def validate_z_code_exists( + connection: Connection, + dos_service: DoSService, + symptom_group_id: int, + symptom_discriminator_id: int, + z_code_alias: str, +) -> bool: + """Validates that the Z code exists in the DoS database. + + Args: + connection (connection): Connection to the DoS database + dos_service (DoSService): The dos service to update + symptom_group_id (int): The symptom group id + symptom_discriminator_id (int): The symptom discriminator id + z_code_alias (str): The z code alias for logging purposes + + Returns: + bool: True if the Z code exists, False if it does not + """ + cursor = query_dos_db( + connection=connection, + query=( + "SELECT id FROM symptomgroupsymptomdiscriminators " + "WHERE symptomgroupid=%(SGID)s AND symptomdiscriminatorid=%(SDID)s;" + ), + query_vars={"SGID": symptom_group_id, "SDID": symptom_discriminator_id}, + ) + symptom_group_symptom_discriminator_combo_rowcount = cursor.rowcount + cursor.close() + + if symptom_group_symptom_discriminator_combo_rowcount == 1: + logger.debug(f"{z_code_alias} Z code exists in the DoS database", z_code_alias=z_code_alias) + return True + + log_generic_change_event_error( + f"{z_code_alias} Z code does not exist in the DoS database", + f"{z_code_alias} Z code does not exist", + f"symptom_group_symptom_discriminator={bool(symptom_group_symptom_discriminator_combo_rowcount)}", + dos_service, + ) + + return False diff --git a/test/performance/create_change_events/__init__.py b/application/service_sync/reject_pending_changes/__init__.py similarity index 100% rename from test/performance/create_change_events/__init__.py rename to application/service_sync/reject_pending_changes/__init__.py diff --git a/application/service_sync/reject_pending_changes/pending_changes.py b/application/service_sync/reject_pending_changes/pending_changes.py new file mode 100644 index 000000000..cedc84b15 --- /dev/null +++ b/application/service_sync/reject_pending_changes/pending_changes.py @@ -0,0 +1,257 @@ +from dataclasses import dataclass +from datetime import datetime +from json import JSONDecodeError, dumps, loads +from os import environ +from time import time_ns +from typing import Self + +from aws_lambda_powertools.logging import Logger +from boto3 import client +from psycopg import Connection +from psycopg.rows import DictRow +from pytz import timezone + +from ..service_update_logger import ServiceUpdateLogger +from .s3 import put_content_to_s3 +from common.constants import DI_CHANGE_ITEMS, DOS_INTEGRATION_USER_NAME +from common.dos_db_connection import connect_to_db_writer, query_dos_db +from common.types import EmailFile, EmailMessage + +logger = Logger(child=True) + + +@dataclass(repr=True) +class PendingChange: + """A class representing a pending change from the DoS database with useful information about the change.""" + + id: str # Id of the pending change from the change table + value: str # Value of the pending change as a JSON string + creatorsname: str # User name of the user who made the change + email: str # Email address of the user who made the change + typeid: str # Type id of the service + name: str # Name of the service + uid: str # Uid of the service + user_id: str # User id of the user who made the change + + def __init__(self: Self, db_cursor_row: dict) -> None: + """Sets the attributes of this object to those found in the db row. + + Args: + db_cursor_row (dict): row from db as key/val pairs. + """ + for row_key, row_value in db_cursor_row.items(): + setattr(self, row_key, row_value) + + def __repr__(self: Self) -> str: + """Returns a string representation of this object. + + Returns: + str: String representation of this object + """ + try: + value = loads(self.value) + value["initiator"]["userid"] = "Hidden in Logs" + value["approver"] = "Hidden in Logs" + except JSONDecodeError: + value = "Unable to show value as unable to decode JSON to remove sensitive user data" + + return ( + f"PendingChange(id={self.id}, value={value}, typeid={self.typeid}, " + f"name={self.name}, uid={self.uid}, user_id={self.user_id})" + ) + + def is_valid(self: Self) -> bool: + """Checks if the pending change is valid. + + Returns: + bool: True if the pending change is valid, False otherwise + """ + try: + value_dict = loads(self.value) + changes = value_dict["new"] + is_types_valid = [change in DI_CHANGE_ITEMS for change in changes] + return all(is_types_valid) + except Exception: + logger.exception( + f"Invalid JSON at pending change {self.id}, unable to show as contains sensitive user data", + ) + return False + + +def check_and_remove_pending_dos_changes(service_id: str) -> None: + """Checks for pending changes in DoS and removes them if they exist. + + Args: + service_id (str): The ID of the service to check + """ + with connect_to_db_writer() as connection: + pending_changes = get_pending_changes(connection=connection, service_id=service_id) + if pending_changes != [] and pending_changes is not None: + logger.info("Pending Changes to be rejected", pending_changes=pending_changes) + reject_pending_changes(connection=connection, pending_changes=pending_changes) + connection.commit() + log_rejected_changes(pending_changes) + send_rejection_emails(pending_changes) + logger.info("All pending changes rejected and emails sent") + else: + logger.info("No valid pending changes found") + + +def get_pending_changes(connection: Connection, service_id: str) -> list[PendingChange] | None: + """Gets pending changes for a service ID. + + Args: + connection (connection): The connection to the DoS database + service_id (str): The ID of the service to check + + Returns: + Optional[List[Dict[str, Any]]]: A list of pending changes or None if there are no pending changes + """ + sql_query = ( + "SELECT c.id, c.value, c.creatorsname, u.email, s.typeid, s.name, s.uid, u.id AS user_id " + "FROM changes c INNER JOIN users u ON u.username = c.creatorsname " + "INNER JOIN services s ON s.id = c.serviceid " + "WHERE serviceid=%(SERVICE_ID)s AND approvestatus='PENDING'" + ) + query_vars = {"SERVICE_ID": service_id} + cursor = query_dos_db(connection=connection, query=sql_query, query_vars=query_vars) + response_rows: list[DictRow] = cursor.fetchall() + cursor.close() + if not response_rows: + return None + logger.info(f"Pending changes found for Service ID {service_id}") + pending_changes: list[PendingChange] = [] + for row in response_rows: + pending_change = PendingChange(row) + logger.info(f"Pending change found: {pending_change}", pending_change=pending_change) + if pending_change.is_valid(): + logger.debug(f"Pending change is valid: {pending_change.id}", pending_change=pending_change) + pending_changes.append(pending_change) + else: + logger.info(f"Pending change {pending_change.id} is invalid", pending_change=pending_change) + + return pending_changes + + +def reject_pending_changes(connection: Connection, pending_changes: list[PendingChange]) -> None: + """Rejects pending changes from the database. + + Args: + connection (connection): The connection to the DoS database + pending_changes (List[PendingChange]): The pending change to reject + """ + conditions = ( + f"id='{pending_changes[0].id}'" + if len(pending_changes) == 1 + else f"""id in ({",".join(f"'{change.id}'" for change in pending_changes)})""" + ) + # SQL Injection is prevented by the query only using data from DoS DB + sql_query = ( + "UPDATE changes SET approvestatus='REJECTED', " # noqa: S608 + "modifiedtimestamp=%(TIMESTAMP)s, modifiersname=%(USER_NAME)s" + f""" WHERE {conditions}""" + ) + query_vars = { + "USER_NAME": DOS_INTEGRATION_USER_NAME, + "TIMESTAMP": datetime.now(timezone("Europe/London")), + } + cursor = query_dos_db(connection=connection, query=sql_query, query_vars=query_vars) + cursor.close() + logger.info("Rejected pending change/s", pending_changes=pending_changes) + + +def log_rejected_changes(pending_changes: list[PendingChange]) -> None: + """Logs the rejected changes. + + Args: + pending_changes (List[PendingChange]): The pending changes to log + """ + for pending_change in pending_changes: + ServiceUpdateLogger( + service_uid=pending_change.uid, + service_name=pending_change.name, + type_id=pending_change.typeid, + odscode="", + ).log_rejected_change(pending_change.id) + + +def send_rejection_emails(pending_changes: list[PendingChange]) -> None: + """Sends rejection emails to the users who created the pending changes. + + Args: + pending_changes (List[PendingChange]): The pending changes to send rejection emails for + """ + subject = "Your DoS Change has been rejected" + for pending_change in pending_changes: + file_name = f"rejection-emails/rejection-email-{time_ns()}.json" + file_contents = build_change_rejection_email_contents(pending_change, file_name) + correlation_id: str = logger.get_correlation_id() + email_file = EmailFile( + correlation_id=correlation_id, + email_body=file_contents, + email_subject=subject, + user_id=pending_change.user_id, + ) + logger.info("Email file created", subject=subject, user_id=pending_change.user_id) + put_content_to_s3(content=dumps(email_file), s3_filename=file_name) + logger.info("File contents uploaded to S3") + file_contents = file_contents.replace("{{InitiatorName}}", pending_change.creatorsname) + message = EmailMessage( + change_id=pending_change.id, + correlation_id=correlation_id, + email_body=file_contents, + email_subject=subject, + recipient_email_address=pending_change.email, + s3_filename=file_name, + user_id=pending_change.user_id, + ) + logger.debug("Email message created") + client("lambda").invoke( + FunctionName=environ["SEND_EMAIL_LAMBDA"], + InvocationType="Event", + Payload=dumps(message), + ) + logger.info("Send email lambda invoked") + + +def build_change_rejection_email_contents(pending_change: PendingChange, file_name: str) -> str: + """Builds the contents of the change rejection email. + + Args: + pending_change (PendingChange): The pending change to build the email for + file_name (str): The name of the file to upload to S3 + + Returns: + str: The contents of the email + """ + with open("service_sync/reject_pending_changes/rejection-email.html") as email_template: + file_contents = email_template.read() + email_template.close() + email_correlation_id = f"{pending_change.uid}-{time_ns()}" + file_contents = file_contents.replace("{{ServiceName}}", pending_change.name) + file_contents = file_contents.replace("{{ServiceUid}}", pending_change.uid) + file_contents = file_contents.replace("{{EmailCorrelationId}}", email_correlation_id) + file_contents = file_contents.replace("{{DiTeamEmail}}", environ.get("TEAM_EMAIL_ADDRESS", "")) + logger.info("Email Correlation Id", email_correlation_id=email_correlation_id, file_name=file_name) + json_value = loads(pending_change.value) + for change_key, value in json_value["new"].items(): + # Add a new change row to the table in the email + row = TABLE_ROW + row = row.replace("{{change_key}}", change_key) + row = row.replace("{{previous}}", str(value.get("previous"))) + row = row.replace("{{new}}", str(value.get("data"))) + file_contents = file_contents.replace("{{row}}", row) + # Remove the placeholder row + file_contents = file_contents.replace("{{row}}", " ") + # Remove the \n characters from the HTML + return file_contents.replace("\n", " ") + + +TABLE_ROW: str = """ + + {{change_key}} + {{previous}} + {{new}} + + {{row}} + """ diff --git a/application/service_sync/reject_pending_changes/rejection-email.html b/application/service_sync/reject_pending_changes/rejection-email.html new file mode 100644 index 000000000..cdf0025b7 --- /dev/null +++ b/application/service_sync/reject_pending_changes/rejection-email.html @@ -0,0 +1,52 @@ + + + + DoS Change Rejection Email + + + + +

Dear {{InitiatorName}},

+
+ +

A change that you made in the DoS has been rejected automatically. This happened because there was a conflict with a new change from DoS Integration.

+

The service is: {{ServiceName}}

+

The service uid: {{ServiceUid}}

+
+

Your rejected change was:

+ + + + + + + + + + + + + {{row}} + +
Change KeyData before requested changeProposed Change
+ +

Please visit DoS to view the new change. If no changes are present DoS Integration considers DoS data to be correct

+
+

Your DoS Integration Email Correlation Id is: {{EmailCorrelationId}}

+
+

Kind regards,

+

DoS Integration

+

(Please do not reply to this email as the mailbox is not monitored. If you have any queries then please email {{DiTeamEmail}} directly)

+ + diff --git a/application/service_sync/reject_pending_changes/s3.py b/application/service_sync/reject_pending_changes/s3.py new file mode 100644 index 000000000..ce314ede5 --- /dev/null +++ b/application/service_sync/reject_pending_changes/s3.py @@ -0,0 +1,18 @@ +from os import getenv + +from aws_lambda_powertools.logging import Logger +from boto3 import client + +logger = Logger(child=True) + + +def put_content_to_s3(content: bytes, s3_filename: str) -> None: + """Upload a file contents to S3. + + Args: + content (bytes): File contents + s3_filename (str): The filename when the file is stored in S3 + """ + bucket = getenv("SEND_EMAIL_BUCKET_NAME") + client("s3").put_object(Body=content, Bucket=bucket, Key=s3_filename, ServerSideEncryption="AES256") + logger.info(f"Uploaded to S3 as {s3_filename}", bucket=bucket, s3_filename=s3_filename) diff --git a/build/docker/authoriser/assets/.gitkeep b/application/service_sync/reject_pending_changes/tests/__init__.py similarity index 100% rename from build/docker/authoriser/assets/.gitkeep rename to application/service_sync/reject_pending_changes/tests/__init__.py diff --git a/application/service_sync/reject_pending_changes/tests/test_pending_changes.py b/application/service_sync/reject_pending_changes/tests/test_pending_changes.py new file mode 100644 index 000000000..71039d4e4 --- /dev/null +++ b/application/service_sync/reject_pending_changes/tests/test_pending_changes.py @@ -0,0 +1,404 @@ +from json import dumps +from os import environ +from random import choices +from unittest.mock import MagicMock, call, patch + +import pytest +from pytz import timezone + +from application.service_sync.reject_pending_changes.pending_changes import ( + PendingChange, + build_change_rejection_email_contents, + check_and_remove_pending_dos_changes, + get_pending_changes, + log_rejected_changes, + reject_pending_changes, + send_rejection_emails, +) + +FILE_PATH = "application.service_sync.reject_pending_changes.pending_changes" +ROW = { + "id": "".join(choices("ABCDEFGHIJKLM", k=8)), + "type": "".join(choices("ABCDEFGHIJKLM", k=8)), + "value": "".join(choices("ABCDEFGHIJKLM", k=8)), + "creatorsname": "".join(choices("ABCDEFGHIJKLM", k=8)), + "email": "".join(choices("ABCDEFGHIJKLM", k=8)), + "typeid": "".join(choices("ABCDEFGHIJKLM", k=8)), + "name": "".join(choices("ABCDEFGHIJKLM", k=8)), + "uid": "".join(choices("ABCDEFGHIJKLM", k=8)), + "user_id": "".join(choices("ABCDEFGHIJKLM", k=8)), +} +EXPECTED_QUERY = ( + "SELECT c.id, c.value, c.creatorsname, u.email, s.typeid, s.name, s.uid, u.id AS user_id " + "FROM changes c INNER JOIN users u ON u.username = c.creatorsname " + "INNER JOIN services s ON s.id = c.serviceid " + "WHERE serviceid=%(SERVICE_ID)s AND approvestatus='PENDING'" +) + + +def test_pending_change() -> None: + # Act + pending_change = PendingChange(ROW) + # Assert + assert pending_change.id == ROW["id"] + assert pending_change.value == ROW["value"] + assert pending_change.creatorsname == ROW["creatorsname"] + assert pending_change.email == ROW["email"] + assert pending_change.typeid == ROW["typeid"] + assert pending_change.name == ROW["name"] + assert pending_change.uid == ROW["uid"] + assert pending_change.user_id == ROW["user_id"] + + +def test_pending_change__repr__() -> None: + # Arrange + row = ROW.copy() + row_value = {"new": {"name": "test"}, "initiator": {"userid": "test"}, "approver": "test"} + row["value"] = dumps(row_value) + pending_change = PendingChange(row) + # Act + response = repr(pending_change) + # Assert + row_value["initiator"]["userid"] = "Hidden in Logs" + row_value["approver"] = "Hidden in Logs" + assert ( + f"""PendingChange(id={ROW["id"]}, value={row_value}, typeid={ROW["typeid"]}, """ + f"""name={ROW["name"]}, uid={ROW["uid"]}, user_id={ROW["user_id"]})""" + ) == response + + +def test_pending_change_is_valid_true() -> None: + # Arrange + pending_change = PendingChange(ROW) + pending_change.value = '{"new": {"cmsurl": "test"}}' + # Act + is_valid = pending_change.is_valid() + # Assert + assert True is is_valid + + +def test_pending_change_is_valid_false() -> None: + # Arrange + pending_change = PendingChange(ROW) + pending_change.value = '{"new": {"name": "test"}}' + # Act + is_valid = pending_change.is_valid() + # Assert + assert False is is_valid + + +def test_pending_change_is_valid_exception() -> None: + # Arrange + pending_change = PendingChange(ROW) + pending_change.value = '{"new": {"name": "test"}' + # Act + is_valid = pending_change.is_valid() + # Assert + assert False is is_valid + + +@patch(f"{FILE_PATH}.send_rejection_emails") +@patch(f"{FILE_PATH}.log_rejected_changes") +@patch(f"{FILE_PATH}.reject_pending_changes") +@patch(f"{FILE_PATH}.get_pending_changes") +@patch(f"{FILE_PATH}.connect_to_db_writer") +def test_check_and_remove_pending_dos_changes( + mock_connect_to_db_writer: MagicMock, + mock_get_pending_changes: MagicMock, + mock_reject_pending_changes: MagicMock, + mock_log_rejected_changes: MagicMock, + mock_send_rejection_emails: MagicMock, +) -> None: + # Arrange + service_id = "test" + mock_get_pending_changes.return_value = get_pending_changes_response = [PendingChange(ROW)] + # Act + response = check_and_remove_pending_dos_changes(service_id) + # Assert + assert None is response + mock_connect_to_db_writer.assert_called_once() + mock_get_pending_changes.assert_called_once_with( + connection=mock_connect_to_db_writer.return_value.__enter__.return_value, + service_id=service_id, + ) + mock_reject_pending_changes.assert_called_once_with( + connection=mock_connect_to_db_writer.return_value.__enter__.return_value, + pending_changes=get_pending_changes_response, + ) + mock_log_rejected_changes.assert_called_once_with(get_pending_changes_response) + mock_send_rejection_emails.assert_called_once_with(get_pending_changes_response) + + +@patch(f"{FILE_PATH}.send_rejection_emails") +@patch(f"{FILE_PATH}.log_rejected_changes") +@patch(f"{FILE_PATH}.reject_pending_changes") +@patch(f"{FILE_PATH}.get_pending_changes") +@patch(f"{FILE_PATH}.connect_to_db_writer") +def test_check_and_remove_pending_dos_changes_no_pending_changes( + mock_connect_to_db_writer: MagicMock, + mock_get_pending_changes: MagicMock, + mock_reject_pending_changes: MagicMock, + mock_log_rejected_changes: MagicMock, + mock_send_rejection_emails: MagicMock, +) -> None: + # Arrange + service_id = "test" + mock_get_pending_changes.return_value = None + # Act + response = check_and_remove_pending_dos_changes(service_id) + # Assert + assert None is response + mock_connect_to_db_writer.assert_called_once() + mock_get_pending_changes.assert_called_once_with( + connection=mock_connect_to_db_writer.return_value.__enter__.return_value, + service_id=service_id, + ) + mock_reject_pending_changes.assert_not_called() + mock_log_rejected_changes.assert_not_called() + mock_send_rejection_emails.assert_not_called() + + +@patch(f"{FILE_PATH}.send_rejection_emails") +@patch(f"{FILE_PATH}.log_rejected_changes") +@patch(f"{FILE_PATH}.reject_pending_changes") +@patch(f"{FILE_PATH}.get_pending_changes") +@patch(f"{FILE_PATH}.connect_to_db_writer") +def test_check_and_remove_pending_dos_changes_invalid_changes( + mock_connect_to_db_writer: MagicMock, + mock_get_pending_changes: MagicMock, + mock_reject_pending_changes: MagicMock, + mock_log_rejected_changes: MagicMock, + mock_send_rejection_emails: MagicMock, +) -> None: + # Arrange + service_id = "test" + mock_get_pending_changes.return_value = [] + # Act + response = check_and_remove_pending_dos_changes(service_id) + # Assert + assert None is response + mock_connect_to_db_writer.assert_called_once() + mock_get_pending_changes.assert_called_once_with( + connection=mock_connect_to_db_writer.return_value.__enter__.return_value, + service_id=service_id, + ) + mock_reject_pending_changes.assert_not_called() + mock_log_rejected_changes.assert_not_called() + mock_send_rejection_emails.assert_not_called() + + +@patch(f"{FILE_PATH}.PendingChange.__repr__") +@patch(f"{FILE_PATH}.PendingChange.is_valid") +@patch(f"{FILE_PATH}.query_dos_db") +def test_get_pending_changes_is_pending_changes_valid_changes( + mock_query_dos_db: MagicMock, + mock_is_valid: MagicMock, + mock_repr: MagicMock, +) -> None: + # Arrange + connection = MagicMock() + service_id = "test" + mock_query_dos_db.return_value.fetchall.return_value = [ROW] + mock_is_valid.return_value = True + mock_repr.return_value = "test" + # Act + response = get_pending_changes(connection, service_id) + # Assert + mock_query_dos_db.assert_called_once_with( + connection=connection, + query=EXPECTED_QUERY, + query_vars={"SERVICE_ID": service_id}, + ) + assert mock_repr.call_count == 2 + mock_is_valid.assert_called_once() + assert PendingChange(ROW) == response[0] + + +@patch(f"{FILE_PATH}.PendingChange.__repr__") +@patch(f"{FILE_PATH}.PendingChange.is_valid") +@patch(f"{FILE_PATH}.query_dos_db") +def test_get_pending_changes_is_pending_changes_invalid_changes( + mock_query_dos_db: MagicMock, + mock_is_valid: MagicMock, + mock_repr: MagicMock, +) -> None: + # Arrange + connection = MagicMock() + service_id = "test" + mock_query_dos_db.return_value.fetchall.return_value = [ROW] + mock_is_valid.return_value = False + mock_repr.return_value = "test" + # Act + response = get_pending_changes(connection, service_id) + # Assert + mock_query_dos_db.assert_called_once_with( + connection=connection, + query=EXPECTED_QUERY, + query_vars={"SERVICE_ID": service_id}, + ) + assert mock_repr.call_count == 3 + mock_is_valid.assert_called_once() + assert response == [] + + +@patch(f"{FILE_PATH}.PendingChange.is_valid") +@patch(f"{FILE_PATH}.query_dos_db") +def test_get_pending_changes_no_changes(mock_query_dos_db: MagicMock, mock_is_valid: MagicMock) -> None: + # Arrange + connection = MagicMock() + service_id = "test" + mock_query_dos_db.return_value.fetchall.return_value = [] + mock_is_valid.return_value = False + # Act + response = get_pending_changes(connection, service_id) + # Assert + mock_query_dos_db.assert_called_once_with( + connection=connection, + query=EXPECTED_QUERY, + query_vars={"SERVICE_ID": service_id}, + ) + mock_is_valid.assert_not_called() + assert None is response + + +@patch(f"{FILE_PATH}.datetime") +@patch(f"{FILE_PATH}.query_dos_db") +def test_reject_pending_changes_single_rejection(mock_query_dos_db: MagicMock, mock_datetime: MagicMock) -> None: + # Arrange + pending_change = PendingChange(ROW) + pending_changes = [pending_change] + connection = MagicMock() + # Act + response = reject_pending_changes(connection, pending_changes) + # Assert + assert None is response + mock_datetime.now.assert_called_once_with(timezone("Europe/London")) + mock_query_dos_db.assert_called_once_with( + connection=connection, + query=( + "UPDATE changes SET approvestatus='REJECTED', " + f"modifiedtimestamp=%(TIMESTAMP)s, modifiersname=%(USER_NAME)s WHERE id='{pending_change.id}'" + ), + query_vars={"USER_NAME": "DOS_INTEGRATION", "TIMESTAMP": mock_datetime.now.return_value}, + ) + + +@patch(f"{FILE_PATH}.datetime") +@patch(f"{FILE_PATH}.query_dos_db") +def test_reject_pending_changes_multiple_rejections(mock_query_dos_db: MagicMock, mock_datetime: MagicMock) -> None: + # Arrange + pending_change1 = PendingChange(ROW) + pending_change1.id = "Change1" + pending_change2 = PendingChange(ROW) + pending_change2.id = "Change2" + pending_change3 = PendingChange(ROW) + pending_change3.id = "Change3" + pending_changes = [pending_change1, pending_change2, pending_change3] + connection = MagicMock() + # Act + response = reject_pending_changes(connection, pending_changes) + # Assert + assert None is response + mock_datetime.now.assert_called_once_with(timezone("Europe/London")) + mock_query_dos_db.assert_called_once_with( + connection=connection, + query=( + "UPDATE changes SET approvestatus='REJECTED', " + f"modifiedtimestamp=%(TIMESTAMP)s, modifiersname=%(USER_NAME)s " + f"WHERE id in ('{pending_change1.id}','{pending_change2.id}','{pending_change3.id}')" + ), + query_vars={"USER_NAME": "DOS_INTEGRATION", "TIMESTAMP": mock_datetime.now.return_value}, + ) + + +def test_log_rejected_changes(capsys: pytest.CaptureFixture) -> None: + # Arrange + pending_change = PendingChange(ROW) + pending_changes = [pending_change] + # Act + response = log_rejected_changes(pending_changes) + # Assert + assert None is response + captured = capsys.readouterr() + assert ( + f"update|None|NULL|DOS_INTEGRATION|RejectDeleteChange|" + f"request|success|action=reject|changeId={pending_change.id}|org_id={pending_change.uid}|" + f"org_name={pending_change.name}|change_status=PENDING|info=change rejected|" + "execution_time=NULL" + ) in captured.err + + +@patch(f"{FILE_PATH}.client") +@patch(f"{FILE_PATH}.EmailMessage") +@patch(f"{FILE_PATH}.build_change_rejection_email_contents") +@patch(f"{FILE_PATH}.time_ns") +@patch(f"{FILE_PATH}.dumps") +@patch("builtins.open") +@patch(f"{FILE_PATH}.put_content_to_s3") +def test_send_rejection_emails( + mock_put_content_to_s3: MagicMock, + mock_open: MagicMock, + mock_dumps: MagicMock, + mock_time_ns: MagicMock, + mock_build_change_rejection_email_contents: MagicMock, + mock_email_message: MagicMock, + mock_client: MagicMock, +) -> None: + # Arrange + environ["SEND_EMAIL_LAMBDA"] = send_email_lambda_name = "test" + pending_change = PendingChange(ROW) + pending_changes = [pending_change] + mock_build_change_rejection_email_contents.return_value = file_contents = "test" + expected_subject = "Your DoS Change has been rejected" + # Act + response = send_rejection_emails(pending_changes) + # Assert + assert None is response + mock_dumps.assert_has_calls( + calls=[ + call( + { + "correlation_id": None, + "user_id": pending_change.user_id, + "email_body": mock_build_change_rejection_email_contents.return_value, + "email_subject": expected_subject, + }, + ), + call(mock_email_message.return_value), + ], + ) + mock_put_content_to_s3.assert_called_once_with( + content=mock_dumps.return_value, + s3_filename=f"rejection-emails/rejection-email-{mock_time_ns.return_value}.json", + ) + mock_email_message.assert_called_once_with( + change_id=pending_change.id, + correlation_id=None, + recipient_email_address=pending_change.email, + email_body=file_contents, + email_subject=expected_subject, + s3_filename=f"rejection-emails/rejection-email-{mock_time_ns.return_value}.json", + user_id=pending_change.user_id, + ) + mock_client.assert_called_once_with("lambda") + mock_client.return_value.invoke.assert_called_once_with( + FunctionName=send_email_lambda_name, + InvocationType="Event", + Payload=mock_dumps.return_value, + ) + # Cleanup + del environ["SEND_EMAIL_LAMBDA"] + + +@patch("builtins.open") +def test_build_change_rejection_email_contents(mock_open: MagicMock) -> None: + # Arrange + pending_change = PendingChange(ROW) + pending_change.value = '{"new":{"cmsurl":{"previous":"test.com","data":"https://www.test.com"}}}' + # Act + response = build_change_rejection_email_contents(pending_change, "test_file") + # Assert + assert ( + response + == mock_open.return_value.__enter__.return_value.read.return_value.replace.return_value.replace.return_value.replace.return_value.replace.return_value.replace.return_value.replace.return_value.replace.return_value # noqa: E501 + ) diff --git a/application/service_sync/reject_pending_changes/tests/test_s3.py b/application/service_sync/reject_pending_changes/tests/test_s3.py new file mode 100644 index 000000000..8c2624e6a --- /dev/null +++ b/application/service_sync/reject_pending_changes/tests/test_s3.py @@ -0,0 +1,26 @@ +from os import environ +from unittest.mock import MagicMock, patch + +from application.service_sync.reject_pending_changes.s3 import put_content_to_s3 + +FILE_PATH = "application.service_sync.reject_pending_changes.s3" + + +@patch(f"{FILE_PATH}.client") +def test_put_content_to_s3(mock_client: MagicMock) -> None: + # Arrange + environ["SEND_EMAIL_BUCKET_NAME"] = bucket_name = "bucket_name" + s3_filename = "s3_filename" + content = b"content" + # Act + put_content_to_s3(content, s3_filename) + # Assert + mock_client.assert_called_once_with("s3") + mock_client.return_value.put_object.assert_called_once_with( + Body=content, + Bucket=bucket_name, + Key=s3_filename, + ServerSideEncryption="AES256", + ) + # Cleanup + del environ["SEND_EMAIL_BUCKET_NAME"] diff --git a/application/service_sync/reporting.py b/application/service_sync/reporting.py new file mode 100644 index 000000000..be1da798f --- /dev/null +++ b/application/service_sync/reporting.py @@ -0,0 +1,186 @@ +from os import getenv + +from aws_lambda_powertools.logging.logger import Logger + +from common.dos import DoSService +from common.nhs import NHSEntity +from common.opening_times import OpenPeriod + +logger = Logger(child=True) + +BLANK_STANDARD_OPENINGS_REPORT_ID = "BLANK_STANDARD_OPENINGS" +GENERIC_BANK_HOLIDAY_REPORT_ID = "GENERIC_BANK_HOLIDAY" +GENERIC_CHANGE_EVENT_ERROR_REPORT_ID = "GENERIC_CHANGE_EVENT_ERROR" +INVALID_POSTCODE_REPORT_ID = "INVALID_POSTCODE" +SERVICE_UPDATE_REPORT_ID = "SERVICE_UPDATE" + + +def log_blank_standard_opening_times( + nhs_entity: NHSEntity, + dos_service: DoSService, +) -> None: + """Log events where matches services are found but no std opening times exist. + + Args: + nhs_entity (NHSEntity): The NHS entity to report + dos_service (DoSService): The list of DoS matching services + """ + logger.warning( + "NHS Service has matching DoS services but no given standard opening times.", + report_key=BLANK_STANDARD_OPENINGS_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + dos_service_id=dos_service.id, + dos_service_uid=dos_service.uid, + dos_service_name=dos_service.name, + nhsuk_service_status=nhs_entity.org_status, + nhsuk_service_type=nhs_entity.org_type, + nhsuk_sector=nhs_entity.org_sub_type, + dos_service_status=dos_service.statusid, + dos_service_type=dos_service.typeid, + dos_service_type_name=dos_service.service_type_name, + dos_region=dos_service.get_region(), + ) + + +def log_invalid_nhsuk_postcode( + nhs_entity: NHSEntity, + dos_service: DoSService, +) -> None: + """Log invalid NHS pharmacy postcode. + + Args: + nhs_entity (NHSEntity): The NHS entity to report + dos_service (DoSService): DoS service to report + """ + error_msg = f"NHS entity '{nhs_entity.odscode}' postcode '{nhs_entity.postcode}' is not a valid DoS postcode!" + logger.warning( + error_msg, + report_key=INVALID_POSTCODE_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_type=nhs_entity.org_type, + nhsuk_organisation_name=nhs_entity.org_name, + nhsuk_organisation_subtype=nhs_entity.org_sub_type, + nhsuk_address1=nhs_entity.entity_data.get("Address1", ""), + nhsuk_address2=nhs_entity.entity_data.get("Address2", ""), + nhsuk_address3=nhs_entity.entity_data.get("Address3", ""), + nhsuk_city=nhs_entity.entity_data.get("City", ""), + nhsuk_county=nhs_entity.entity_data.get("County", ""), + nhsuk_postcode=nhs_entity.postcode, + validation_error_reason="Postcode not valid/found on DoS", + dos_service=dos_service.uid, + dos_service_type_name=dos_service.service_type_name, + dos_region=dos_service.get_region(), + dos_service_name=dos_service.name, + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="InvalidPostcode", + ) + + +def log_service_with_generic_bank_holiday( + nhs_entity: NHSEntity, + dos_service: DoSService, +) -> None: + """Log a service found to have a generic bank holiday open times set in DoS. + + Args: + nhs_entity (NHSEntity): The NHS entity to report + dos_service (DoSService): The DoS service to report + """ + open_periods_str = OpenPeriod.list_string( + dos_service.standard_opening_times.generic_bankholiday, + ) + + logger.warning( + f"DoS Service uid={dos_service.uid} has a generic BankHoliday Standard opening time set in DoS", + report_key=GENERIC_BANK_HOLIDAY_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_name=nhs_entity.org_name, + dos_service_uid=dos_service.uid, + dos_service_name=dos_service.name, + bank_holiday_opening_times=open_periods_str, + nhsuk_parent_org=nhs_entity.parent_org_name, + dos_service_type_name=dos_service.service_type_name, + dos_region=dos_service.get_region(), + ) + + +def log_website_is_invalid(nhs_uk_entity: NHSEntity, nhs_website: str, dos_service: DoSService) -> None: + """Log a service found to have an invalid website. + + Args: + nhs_uk_entity (NHSEntity): The NHS entity to report + nhs_website (str): The NHS website to report + dos_service (DoSService): The DoS service to report + """ + log_generic_change_event_error( + "Website is not valid", + "Website is not valid", + f"NHSUK unedited website: '{nhs_uk_entity.website}', NHSUK website='{nhs_website}'", + dos_service, + nhs_unedited_website=nhs_uk_entity.website, + nhs_website=nhs_website, + ) + + +def log_generic_change_event_error( + message: str, + error_reason: str, + error_info: str, + dos_service: DoSService, + **kwargs: tuple[str, str], +) -> None: + """Log a generic change event error. + + Args: + message (str): The message to log + error_reason (str): The error reason + error_info (str): The error info + dos_service (DoSService): The DoS service to report + **kwargs (tuple[str, str]): Additional key value pairs to log + """ + logger.warning( + message, + report_key=GENERIC_CHANGE_EVENT_ERROR_REPORT_ID, + error_reason=error_reason, + error_info=error_info, + dos_region=dos_service.get_region(), + **kwargs, + ) + + +def log_service_updated( + action: str, + data_field_modified: str, + new_value: str, + previous_value: str, + service_name: str, + service_uid: str, + type_id: str, + dos_service: DoSService, +) -> None: + """Log a service update. + + Args: + action (str): The action performed + data_field_modified (str): The data field modified + new_value (str): The new value + previous_value (str): The previous value + service_name (str): The service name + service_uid (str): The service uid + type_id (str): The type id + dos_service (DoSService): The DoS service to report + """ + logger.warning( + "Service update complete", + report_key=SERVICE_UPDATE_REPORT_ID, + action=action, + previous_value=previous_value, + new_value=new_value, + data_field_modified=data_field_modified, + service_name=service_name, + service_uid=service_uid, + type_id=type_id, + dos_region=dos_service.get_region(), + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="ServiceUpdate", + ) diff --git a/application/service_sync/requirements.txt b/application/service_sync/requirements.txt new file mode 100644 index 000000000..6c5b01dad --- /dev/null +++ b/application/service_sync/requirements.txt @@ -0,0 +1,3 @@ +aws-lambda-powertools[tracer] ~= 2.43.0 +psycopg[binary] +pytz diff --git a/application/service_sync/service_sync.py b/application/service_sync/service_sync.py new file mode 100644 index 000000000..880603ad0 --- /dev/null +++ b/application/service_sync/service_sync.py @@ -0,0 +1,87 @@ +from os import getenv +from time import time_ns +from typing import Any + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.tracing import Tracer +from aws_lambda_powertools.utilities.data_classes import SQSEvent, event_source +from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord +from aws_lambda_powertools.utilities.typing import LambdaContext +from boto3 import client + +from .data_processing.check_for_change import compare_nhs_uk_and_dos_data +from .data_processing.get_data import get_dos_service_and_history +from .data_processing.update_dos import update_dos_data +from .reject_pending_changes.pending_changes import check_and_remove_pending_dos_changes +from common.middlewares import unhandled_exception_logging +from common.nhs import NHSEntity +from common.types import UpdateRequest +from common.utilities import extract_body + +tracer = Tracer() +logger = Logger() + + +@tracer.capture_lambda_handler() +@unhandled_exception_logging +@logger.inject_lambda_context(clear_state=True) +@event_source(data_class=SQSEvent) +def lambda_handler(event: SQSEvent, context: LambdaContext) -> None: # noqa: ARG001 + """Entrypoint handler for the service_sync lambda. + + Args: + event (SQSEvent): Lambda function invocation event + context (LambdaContext): Lambda function context object + """ + try: + record: SQSRecord = next(event.records) + update_request: UpdateRequest = extract_body(record.body) + logger.set_correlation_id(str(record.message_attributes.get("correlation_id", {}).get("stringValue"))) + logger.append_keys( + ods_code=update_request["change_event"].get("ODSCode"), + service_id=update_request["service_id"], + ) + service_id: str = update_request["service_id"] + check_and_remove_pending_dos_changes(service_id) + # Set up NHS UK Service + change_event: dict[str, Any] = update_request["change_event"] + nhs_entity = NHSEntity(change_event) + # Get current DoS state + dos_service, service_histories = get_dos_service_and_history(service_id=int(service_id)) + # Compare NHS UK and DoS data + changes_to_dos = compare_nhs_uk_and_dos_data( + dos_service=dos_service, + nhs_entity=nhs_entity, + service_histories=service_histories, + ) + # Update Service History with changes to be made + service_histories = changes_to_dos.service_histories + # Update DoS data + update_dos_data(changes_to_dos=changes_to_dos, service_id=int(service_id), service_histories=service_histories) + # Delete the message from the queue + remove_sqs_message_from_queue(receipt_handle=record.receipt_handle) + # Log custom metrics + logger.warning( + "Update Request Success", + latency=(time_ns() // 1000000) + - int(record.message_attributes.get("message_received", {}).get("stringValue")), + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="UpdateRequestSuccess", + ) + except Exception: + logger.exception( + "Error processing update request", + environment=getenv("ENVIRONMENT"), + cloudwatch_metric_filter_matching_attribute="UpdateRequestError", + ) + + +def remove_sqs_message_from_queue(receipt_handle: str) -> None: + """Removes the SQS message from the queue. + + Args: + receipt_handle (str): The SQS message receipt handle + """ + sqs = client("sqs") + sqs.delete_message(QueueUrl=getenv("UPDATE_REQUEST_QUEUE_URL"), ReceiptHandle=receipt_handle) + logger.info("Removed SQS message from queue", receipt_handle=receipt_handle) diff --git a/application/service_sync/service_update_logger.py b/application/service_sync/service_update_logger.py new file mode 100644 index 000000000..593d9b88c --- /dev/null +++ b/application/service_sync/service_update_logger.py @@ -0,0 +1,300 @@ +from itertools import chain +from logging import INFO, Formatter, Logger, StreamHandler +from os import getenv +from typing import Any, Self + +from aws_lambda_powertools.logging import Logger as PowerToolsLogger + +from .data_processing.changes_to_dos import ChangesToDoS +from .data_processing.service_histories import ServiceHistories +from .reporting import log_service_updated +from common.constants import ( + DOS_INTEGRATION_USER_NAME, + DOS_SGSDID_CHANGE_KEY, + DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_CHANGE_KEY_LIST, +) +from common.dos import DoSService +from common.opening_times import SpecifiedOpeningTime, StandardOpeningTimes, opening_period_times_from_list + +logger = PowerToolsLogger(child=True) + + +class ServiceUpdateLogger: + """A class to handle specfic logs to be sent to DoS Splunk.""" + + NULL_VALUE: str = "NULL" + dos_basic_format = "%(asctime)s|%(levelname)s|DOS_INTEGRATION_%(environment)s|%(message)s" + dos_logger: Logger + logger: PowerToolsLogger + dos_service: DoSService | None + + def __init__( + self: Self, + service_uid: str, + service_name: str, + type_id: str, + odscode: str, + dos_service: DoSService = None, + ) -> None: + """Initialise the ServiceUpdateLogger. + + Args: + service_uid (str): The service uid + service_name (str): The service name + type_id (str): The service type id + odscode (str): The service odscode + dos_service (DoSService, optional): The DoSService object. Defaults to None. + """ + # Create new logger / get existing logger + self.dos_logger = Logger("dos_logger") + self.logger = PowerToolsLogger(child=True) + # Set to log to stdout + stream_handler = StreamHandler() + # Set the format of the log message + stream_handler.setFormatter(Formatter(self.dos_basic_format)) + # Add the stream handler to the logger + self.dos_logger.addHandler(stream_handler) + self.dos_logger.setLevel(INFO) + # Extra fields to be set in the logger + self.service_uid = service_uid + self.service_name = service_name + self.type_id = type_id + self.odscode = odscode + self.correlation_id = self.logger.get_correlation_id() + self.environment = getenv("ENVIRONMENT", "UNKNOWN").upper() + self.dos_service = dos_service + + def get_opening_times_change( + self: Self, + data_field_modified: str, + previous_value: str | None, + new_value: str | None, + ) -> tuple[str, str]: + """Get the opening times change in the format required for the log message. + + Args: + data_field_modified (str): The dos change name for field that was modified e.g cmsopentimemonday + previous_value (Optional[str]): The previous value of the field + new_value (Optional[str]): The new value of the field + + Returns: + tuple[str, str]: The formatted previous and new values + """ + existing_value = f"{data_field_modified}_existing={previous_value}" if previous_value else previous_value + if previous_value and new_value: + # Modify + updated_value = f"{data_field_modified}_update=remove={previous_value}add={new_value}" + elif not new_value: + # Remove + updated_value = f"{data_field_modified}_update=remove={previous_value}" + else: + # Add + updated_value = f"{data_field_modified}_update=add={new_value}" + return existing_value, updated_value + + def log_service_update( + self: Self, + data_field_modified: str, + action: str, + previous_value: str | None, + new_value: str | None, + ) -> None: + """Logs a service update to DoS Splunk. + + Args: + data_field_modified (str): The dos change name for field that was modified e.g cmsurl + action (str): The action that was performed e.g add, remove, update + previous_value (Optional[str]): The previous value of the field + new_value (Optional[str]): The new value of the field + """ + # Handle the case where the values could be None + previous_value = "" if previous_value in ["None", "", None] else f'"{previous_value}"' + new_value = "" if new_value in ["None", "", None] else f'"{new_value}"' + # Log the message with all the extra fields set + log_service_updated( + action=action, + data_field_modified=data_field_modified, + new_value=new_value, + previous_value=previous_value, + service_name=self.service_name, + service_uid=self.service_uid, + type_id=self.type_id, + dos_service=self.dos_service, + ) + + self.dos_logger.info( + msg=( + f"{self.correlation_id}|{DOS_INTEGRATION_USER_NAME}|{self.NULL_VALUE}|{self.service_uid}|" + f"{self.service_name}|{self.type_id}|{data_field_modified}|{action}|" + f"{previous_value}|{new_value}|{self.NULL_VALUE}|message=UpdateService|" + f"correlationId={self.correlation_id}|elapsedTime={self.NULL_VALUE}|execution_time={self.NULL_VALUE}" + ), + extra={"environment": self.environment}, + ) + + def log_standard_opening_times_service_update_for_weekday( + self: Self, + data_field_modified: str, + action: str, + previous_value: StandardOpeningTimes | str, + new_value: StandardOpeningTimes | str, + weekday: str, + ) -> None: + """Logs a service update to DoS Splunk for a standard opening times update. + + Args: + data_field_modified (str): The dos change name for field that was modified e.g cmsopentimemonday + action (str): The action that was performed e.g add, remove, update + previous_value (Union[StandardOpeningTimes, str]): The previous value of the field or empty string if none + new_value (Union[StandardOpeningTimes, str]): The new value of the field or empty string if none + weekday (str): The weekday to log the update for e.g monday + """ + previous_value = ( + previous_value + if isinstance(previous_value, str) + else opening_period_times_from_list(open_periods=previous_value.get_openings(weekday), with_space=False) + ) + new_value = ( + new_value + if isinstance(new_value, str) + else opening_period_times_from_list(open_periods=new_value.get_openings(weekday), with_space=False) + ) + existing_value, updated_value = self.get_opening_times_change(data_field_modified, previous_value, new_value) + + self.log_service_update( + data_field_modified=data_field_modified, + action=action, + previous_value=existing_value, + new_value=updated_value, + ) + + def log_specified_opening_times_service_update( + self: Self, + action: str, + previous_value: list[SpecifiedOpeningTime] | None, + new_value: list[SpecifiedOpeningTime] | None, + ) -> None: + """Logs a service update to DoS Splunk for a specified opening times update. + + Args: + action (str): The action that was performed e.g add, remove, update + previous_value (Optional[List[SpecifiedOpeningTime]]): The previous value of the field or none + new_value (Optional[List[SpecifiedOpeningTime]]): The new value of the field or none + """ + + def get_and_format_specified_opening_times( + specified_opening_times: list[SpecifiedOpeningTime] | None, + ) -> str: + specified_opening_times = ( + [specified_opening_time.export_dos_log_format() for specified_opening_time in specified_opening_times] + if specified_opening_times is not None + else "" + ) + return ( + ",".join(list(chain.from_iterable(specified_opening_times))) + if isinstance(specified_opening_times, list) + else "" + ) + + previous_value = get_and_format_specified_opening_times(previous_value) + new_value = get_and_format_specified_opening_times(new_value) + existing_value, updated_value = self.get_opening_times_change( + DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + previous_value, + new_value, + ) + + self.log_service_update( + data_field_modified=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + action=action, + previous_value=existing_value, + new_value=updated_value, + ) + + def log_rejected_change(self: Self, change_id: str) -> None: + """Logs a rejected change to DoS Splunk. + + Args: + change_id (str): The change id to log + """ + self.dos_logger.info( + msg=( + f"update|{self.correlation_id}|{self.NULL_VALUE}|{DOS_INTEGRATION_USER_NAME}|RejectDeleteChange|" + f"request|success|action=reject|changeId={change_id}|org_id={self.service_uid}|" + f"org_name={self.service_name}|change_status=PENDING|info=change rejected|" + f"execution_time={self.NULL_VALUE}" + ), + extra={"environment": self.environment}, + ) + + def log_sgsdid_service_update(self: Self, action: str, new_value: str) -> None: + """Logs a service update to DoS Splunk for a sgsdid update. + + Args: + action (str): The action that was performed e.g add, remove, update + new_value (str): The new value of the field + """ + add_or_remove = "add" if action == "add" else "remove" + self.log_service_update( + data_field_modified=DOS_SGSDID_CHANGE_KEY, + action=action, + previous_value="", + new_value=f"cmssgsdid_update={add_or_remove}={new_value}", + ) + + +def log_service_updates(changes_to_dos: ChangesToDoS, service_histories: ServiceHistories) -> None: + """Logs all service updates to DI Splunk and DoS Splunk. + + This is called after the service has been updated to guarantee + that all updates have been saved to reduce chance of duplicate updates being logged. + + Args: + changes_to_dos (ChangesToDoS): The changes to dos + service_histories (ServiceHistories): The service history for service + """ + service_update_logger = ServiceUpdateLogger( + service_uid=str(changes_to_dos.dos_service.uid), + service_name=changes_to_dos.dos_service.name, + type_id=str(changes_to_dos.dos_service.typeid), + odscode=str(changes_to_dos.nhs_entity.odscode), + dos_service=changes_to_dos.dos_service, + ) + most_recent_service_history_entry = next(iter(service_histories.service_history.keys())) + service_history_changes: dict[str, str] = service_histories.service_history[most_recent_service_history_entry][ + "new" + ] + for change_key, change_values in service_history_changes.items(): + change_key: str + change_values: dict[str, Any] + if change_key == DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY: + service_update_logger.log_specified_opening_times_service_update( + action=change_values.get("changetype", "UNKNOWN"), + previous_value=changes_to_dos.current_specified_opening_times, + new_value=changes_to_dos.new_specified_opening_times, + ) + elif change_key in DOS_STANDARD_OPENING_TIMES_CHANGE_KEY_LIST: + service_update_logger.log_standard_opening_times_service_update_for_weekday( + data_field_modified=change_key, + action=change_values.get("changetype", "UNKNOWN"), + previous_value=changes_to_dos.dos_service.standard_opening_times, + new_value=changes_to_dos.nhs_entity.standard_opening_times, + weekday=change_key.removeprefix("cmsopentime"), + ) + elif change_key == DOS_SGSDID_CHANGE_KEY: + service_update_logger.log_sgsdid_service_update( + action=change_values.get("changetype", "UNKNOWN"), + new_value=change_values.get("data", {}).get( + "add" if change_values.get("changetype") != "delete" else "remove", + ["UNKNOWN"], + )[0], + ) + else: + service_update_logger.log_service_update( + data_field_modified=change_key, + action=change_values["changetype"], + previous_value=change_values.get("previous", ""), + new_value=change_values["data"], + ) + logger.debug(f"Logging service update for change key {change_key}", change_values=change_values) diff --git a/build/docker/cr-fifo-dlq-handler/assets/.gitkeep b/application/service_sync/tests/__init__.py similarity index 100% rename from build/docker/cr-fifo-dlq-handler/assets/.gitkeep rename to application/service_sync/tests/__init__.py diff --git a/application/service_sync/tests/test_reporting.py b/application/service_sync/tests/test_reporting.py new file mode 100644 index 000000000..766220a19 --- /dev/null +++ b/application/service_sync/tests/test_reporting.py @@ -0,0 +1,179 @@ +from unittest.mock import MagicMock, patch + +from aws_lambda_powertools.logging import Logger + +from application.conftest import dummy_dos_service +from application.service_sync.reporting import ( + BLANK_STANDARD_OPENINGS_REPORT_ID, + GENERIC_BANK_HOLIDAY_REPORT_ID, + GENERIC_CHANGE_EVENT_ERROR_REPORT_ID, + INVALID_POSTCODE_REPORT_ID, + SERVICE_UPDATE_REPORT_ID, + log_blank_standard_opening_times, + log_invalid_nhsuk_postcode, + log_service_updated, + log_service_with_generic_bank_holiday, + log_website_is_invalid, +) +from common.nhs import NHSEntity +from common.opening_times import OpenPeriod + + +@patch.object(Logger, "warning") +def test_log_blank_standard_opening_times(mock_logger: MagicMock, change_event: dict[str, str]) -> None: + # Arrange + nhs_entity = NHSEntity(change_event) + dos_service = dummy_dos_service() + dos_service.region = "London" + # Act + log_blank_standard_opening_times(nhs_entity, dos_service) + # Assert + assert ( + BLANK_STANDARD_OPENINGS_REPORT_ID == "BLANK_STANDARD_OPENINGS" + ), f"Report ID should be BLANK_STANDARD_OPENINGS but was {BLANK_STANDARD_OPENINGS_REPORT_ID}" + mock_logger.assert_called_with( + "NHS Service has matching DoS services but no given standard opening times.", + report_key=BLANK_STANDARD_OPENINGS_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + dos_service_id=dos_service.id, + dos_service_uid=dos_service.uid, + dos_service_name=dos_service.name, + nhsuk_service_status=nhs_entity.org_status, + nhsuk_service_type=nhs_entity.org_type, + nhsuk_sector=nhs_entity.org_sub_type, + dos_service_status=dos_service.statusid, + dos_service_type=dos_service.typeid, + dos_service_type_name=dos_service.service_type_name, + dos_region=dos_service.get_region(), + ) + + +@patch.object(Logger, "warning") +def test_log_invalid_nhsuk_postcode(mock_logger: MagicMock) -> None: + # Arrange + county = "county" + city = "city" + nhs_entity = NHSEntity( + {"Address1": "address1", "Address2": "address2", "Address3": "address3", "City": city, "County": county}, + ) + nhs_entity.odscode = "SLC4X" + nhs_entity.org_name = "OrganisationName" + nhs_entity.org_type_id = "PHA" + nhs_entity.org_status = "OrganisationStatus" + nhs_entity.org_sub_type = "OrganisationSubType" + nhs_entity.postcode = "MK2 XXX" + + dos_service = dummy_dos_service() + # Act + log_invalid_nhsuk_postcode(nhs_entity, dos_service) + # Assert + assert ( + INVALID_POSTCODE_REPORT_ID == "INVALID_POSTCODE" + ), f"Log ID should be INVALID_POSTCODE but was {INVALID_POSTCODE_REPORT_ID}" + mock_logger.assert_called_with( + f"NHS entity '{nhs_entity.odscode}' postcode '{nhs_entity.postcode}' is not a valid DoS postcode!", + report_key=INVALID_POSTCODE_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_type=nhs_entity.org_type, + nhsuk_organisation_name=nhs_entity.org_name, + nhsuk_organisation_subtype=nhs_entity.org_sub_type, + nhsuk_address1=nhs_entity.entity_data.get("Address1", ""), + nhsuk_address2=nhs_entity.entity_data.get("Address2", ""), + nhsuk_address3=nhs_entity.entity_data.get("Address3", ""), + nhsuk_city=nhs_entity.entity_data.get("City", ""), + nhsuk_county=nhs_entity.entity_data.get("County", ""), + nhsuk_postcode=nhs_entity.postcode, + validation_error_reason="Postcode not valid/found on DoS", + dos_service=dos_service.uid, + dos_service_type_name=dos_service.service_type_name, + dos_region=dos_service.get_region(), + dos_service_name=dos_service.name, + environment="local", + cloudwatch_metric_filter_matching_attribute="InvalidPostcode", + ) + + +@patch.object(Logger, "warning") +def test_log_service_with_generic_bank_holiday(mock_logger: MagicMock) -> None: + # Arrange + nhs_entity = NHSEntity({}) + nhs_entity.odscode = "SLC4X" + nhs_entity.org_name = "OrganisationName" + dos_service = dummy_dos_service() + open_periods = [OpenPeriod.from_string_times("08:00", "13:00"), OpenPeriod.from_string_times("04:00", "18:00")] + dos_service.standard_opening_times.generic_bankholiday = open_periods + # Act + log_service_with_generic_bank_holiday(nhs_entity, dos_service) + # Assert + mock_logger.assert_called_with( + f"DoS Service uid={dos_service.uid} has a generic BankHoliday Standard opening time set in DoS", + report_key=GENERIC_BANK_HOLIDAY_REPORT_ID, + nhsuk_odscode=nhs_entity.odscode, + nhsuk_organisation_name=nhs_entity.org_name, + dos_service_uid=dos_service.uid, + dos_service_name=dos_service.name, + bank_holiday_opening_times=OpenPeriod.list_string(open_periods), + nhsuk_parent_org=nhs_entity.parent_org_name, + dos_service_type_name=dos_service.service_type_name, + dos_region=dos_service.get_region(), + ) + + +@patch.object(Logger, "warning") +def test_log_website_is_invalid(mock_logger: MagicMock) -> None: + # Arrange + nhs_entity = NHSEntity({}) + nhs_entity.website = nhs_website = "http://www.google.com" + dos_service = dummy_dos_service() + # Act + log_website_is_invalid(nhs_entity, nhs_website, dos_service) + # Assert + mock_logger.assert_called_with( + "Website is not valid", + report_key=GENERIC_CHANGE_EVENT_ERROR_REPORT_ID, + error_reason="Website is not valid", + error_info=f"NHSUK unedited website: '{nhs_entity.website}', NHSUK website='{nhs_website}'", + dos_region=dos_service.get_region(), + nhs_unedited_website=nhs_entity.website, + nhs_website=nhs_website, + ) + + +@patch.object(Logger, "warning") +def test_log_service_updated(mock_logger: MagicMock) -> None: + # Arrange + action = "action" + data_field_modified = "data_field_modified" + new_value = "new_value" + previous_value = "previous_value" + service_name = "ServiceName" + service_uid = "1234567890" + type_id = "1" + dos_service = dummy_dos_service() + # Act + log_service_updated( + action=action, + data_field_modified=data_field_modified, + new_value=new_value, + previous_value=previous_value, + service_name=service_name, + service_uid=service_uid, + type_id=type_id, + dos_service=dos_service, + ) + # Assert + assert SERVICE_UPDATE_REPORT_ID == "SERVICE_UPDATE" + mock_logger.assert_called_with( + "Service update complete", + report_key=SERVICE_UPDATE_REPORT_ID, + action=action, + previous_value=previous_value, + new_value=new_value, + data_field_modified=data_field_modified, + service_name=service_name, + service_uid=service_uid, + type_id=type_id, + dos_region=dos_service.get_region(), + environment="local", + cloudwatch_metric_filter_matching_attribute="ServiceUpdate", + ) diff --git a/application/service_sync/tests/test_service_sync.py b/application/service_sync/tests/test_service_sync.py new file mode 100644 index 000000000..adfeb786c --- /dev/null +++ b/application/service_sync/tests/test_service_sync.py @@ -0,0 +1,139 @@ +from json import dumps +from os import environ +from unittest.mock import MagicMock, patch + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.utilities.typing import LambdaContext + +from application.service_sync.service_sync import lambda_handler, remove_sqs_message_from_queue +from common.types import UpdateRequest + +FILE_PATH = "application.service_sync.service_sync" + +SERVICE_ID = "1" +CHANGE_EVENT = {"ODSCode": "12345"} +RECEIPT_HANDLE = "receipt_handle" +MESSAGE_RECEIVED = "1683017134" + +SQS_EVENT = { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": RECEIPT_HANDLE, + "body": dumps(UpdateRequest(change_event=CHANGE_EVENT, service_id=SERVICE_ID)), + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1642619743522", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185", + }, + "messageAttributes": { + "correlation-id": {"stringValue": "1", "dataType": "String"}, + "sequence-number": {"stringValue": "1", "dataType": "Number"}, + "message_received": {"stringValue": MESSAGE_RECEIVED, "dataType": "Number"}, + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2", + }, + ], +} + + +@patch(f"{FILE_PATH}.check_and_remove_pending_dos_changes") +@patch(f"{FILE_PATH}.remove_sqs_message_from_queue") +@patch(f"{FILE_PATH}.update_dos_data") +@patch(f"{FILE_PATH}.compare_nhs_uk_and_dos_data") +@patch(f"{FILE_PATH}.get_dos_service_and_history") +@patch(f"{FILE_PATH}.NHSEntity") +def test_lambda_handler( + mock_nhs_entity: MagicMock, + mock_get_dos_service_and_history: MagicMock, + mock_compare_nhs_uk_and_dos_data: MagicMock, + mock_update_dos_data: MagicMock, + mock_remove_sqs_message_from_queue: MagicMock, + mock_check_and_remove_pending_dos_changes: MagicMock, + lambda_context: LambdaContext, +) -> None: + # Arrange + environ["ENV"] = "environment" + dos_service = MagicMock() + service_histories = MagicMock() + nhs_entity = MagicMock() + mock_nhs_entity.return_value = nhs_entity + mock_get_dos_service_and_history.return_value = dos_service, service_histories + # Act + lambda_handler(event=SQS_EVENT, context=lambda_context) + # Assert + mock_check_and_remove_pending_dos_changes.assert_called_once_with(SERVICE_ID) + mock_nhs_entity.assert_called_once_with(CHANGE_EVENT) + mock_get_dos_service_and_history.assert_called_once_with(service_id=int(SERVICE_ID)) + mock_compare_nhs_uk_and_dos_data.assert_called_once_with( + dos_service=dos_service, + nhs_entity=nhs_entity, + service_histories=service_histories, + ) + mock_update_dos_data.assert_called_once_with( + changes_to_dos=mock_compare_nhs_uk_and_dos_data(), + service_id=int(SERVICE_ID), + service_histories=mock_compare_nhs_uk_and_dos_data().service_histories, + ) + mock_remove_sqs_message_from_queue.assert_called_once_with(receipt_handle=RECEIPT_HANDLE) + # Cleanup + del environ["ENV"] + + +@patch(f"{FILE_PATH}.check_and_remove_pending_dos_changes") +@patch.object(Logger, "exception") +@patch(f"{FILE_PATH}.remove_sqs_message_from_queue") +@patch(f"{FILE_PATH}.update_dos_data") +@patch(f"{FILE_PATH}.compare_nhs_uk_and_dos_data") +@patch(f"{FILE_PATH}.get_dos_service_and_history") +@patch(f"{FILE_PATH}.NHSEntity") +def test_lambda_handler_exception( + mock_nhs_entity: MagicMock, + mock_get_dos_service_and_history: MagicMock, + mock_compare_nhs_uk_and_dos_data: MagicMock, + mock_update_dos_data: MagicMock, + mock_remove_sqs_message_from_queue: MagicMock, + mock_logger_exception: MagicMock, + mock_check_and_remove_pending_dos_changes: MagicMock, + lambda_context: LambdaContext, +) -> None: + # Arrange + nhs_entity = MagicMock() + mock_nhs_entity.return_value = nhs_entity + mock_get_dos_service_and_history.side_effect = Exception("Error") + # Act + lambda_handler(event=SQS_EVENT, context=lambda_context) + # Assert + mock_check_and_remove_pending_dos_changes.assert_called_once_with(SERVICE_ID) + mock_nhs_entity.assert_called_once_with(CHANGE_EVENT) + mock_get_dos_service_and_history.assert_called_once_with(service_id=int(SERVICE_ID)) + mock_compare_nhs_uk_and_dos_data.assert_not_called() + mock_update_dos_data.assert_not_called() + mock_remove_sqs_message_from_queue.assert_not_called() + mock_logger_exception.assert_called_once_with( + "Error processing update request", + environment="local", + cloudwatch_metric_filter_matching_attribute="UpdateRequestError", + ) + + +@patch.object(Logger, "info") +@patch(f"{FILE_PATH}.client") +def test_remove_sqs_message_from_queue(mock_client: MagicMock, mock_logger_info: MagicMock) -> None: + # Arrange + environ["UPDATE_REQUEST_QUEUE_URL"] = update_request_queue_url = "update_request_queue_url" + # Act + remove_sqs_message_from_queue(receipt_handle=RECEIPT_HANDLE) + # Assert + mock_client.assert_called_once_with("sqs") + mock_client.return_value.delete_message.assert_called_once_with( + QueueUrl=update_request_queue_url, + ReceiptHandle=RECEIPT_HANDLE, + ) + mock_logger_info.assert_called_once_with("Removed SQS message from queue", receipt_handle=RECEIPT_HANDLE) + # Cleanup + del environ["UPDATE_REQUEST_QUEUE_URL"] diff --git a/application/service_sync/tests/test_service_update_logger.py b/application/service_sync/tests/test_service_update_logger.py new file mode 100644 index 000000000..3321ae0ff --- /dev/null +++ b/application/service_sync/tests/test_service_update_logger.py @@ -0,0 +1,365 @@ +from datetime import date, time +from logging import INFO +from unittest.mock import MagicMock, patch + +import pytest + +from application.common.constants import ( + DOS_INTEGRATION_USER_NAME, + DOS_PALLIATIVE_CARE_SGSDID, + DOS_SGSDID_CHANGE_KEY, + DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + DOS_STANDARD_OPENING_TIMES_FRIDAY_CHANGE_KEY, +) +from application.common.opening_times import OpenPeriod, SpecifiedOpeningTime +from application.service_sync.service_update_logger import ServiceUpdateLogger, log_service_updates + +SERVICE_UID = "12345" +SERVICE_NAME = "Test Service" +TYPE_ID = "1" +ODSCODE = "ODS123" +EXAMPLE_DATA_FIELD_MODIFIED = "test_field" +EXAMPLE_ACTION = "update" +EXAMPLE_PREVIOUS_VALUE = "test_value" +EXAMPLE_NEW_VALUE = "new_test_value" +NULL_VALUE = "NULL" +FILE_PATH = "application.service_sync.service_update_logger" + + +@pytest.fixture() +def service_update_logger() -> ServiceUpdateLogger: + return ServiceUpdateLogger(service_uid=SERVICE_UID, service_name=SERVICE_NAME, type_id=TYPE_ID, odscode=ODSCODE) + + +def test_dos_logger(service_update_logger: ServiceUpdateLogger) -> None: + # Assert + assert service_update_logger.logger.name == "service_undefined.application.service_sync.service_update_logger" + assert service_update_logger.dos_logger.name == "dos_logger" + assert service_update_logger.dos_logger.level == INFO + assert ( + service_update_logger.dos_basic_format + == "%(asctime)s|%(levelname)s|DOS_INTEGRATION_%(environment)s|%(message)s" + ) + assert service_update_logger.service_uid == SERVICE_UID + assert service_update_logger.service_name == SERVICE_NAME + assert service_update_logger.type_id == TYPE_ID + + +def test_service_update_logger_get_opening_times_change_modify(service_update_logger: ServiceUpdateLogger) -> None: + # Arrange + previous_value = "Test123" + new_value = "321Test" + data_field_modified = "test_field" + # Act + response = service_update_logger.get_opening_times_change( + data_field_modified=data_field_modified, + previous_value=previous_value, + new_value=new_value, + ) + assert ( + f"{data_field_modified}_existing={previous_value}", + f"{data_field_modified}_update=remove={previous_value}add={new_value}", + ) == response + + +def test_service_update_logger_get_opening_times_change_remove(service_update_logger: ServiceUpdateLogger) -> None: + # Arrange + previous_value = "Test123" + new_value = "" + data_field_modified = "test_field" + # Act + response = service_update_logger.get_opening_times_change( + data_field_modified=data_field_modified, + previous_value=previous_value, + new_value=new_value, + ) + assert ( + f"{data_field_modified}_existing={previous_value}", + f"{data_field_modified}_update=remove={previous_value}", + ) == response + + +def test_service_update_logger_get_opening_times_change_add(service_update_logger: ServiceUpdateLogger) -> None: + # Arrange + previous_value = "" + new_value = "321Test" + data_field_modified = "test_field" + # Act + response = service_update_logger.get_opening_times_change( + data_field_modified=data_field_modified, + previous_value=previous_value, + new_value=new_value, + ) + assert ("", f"{data_field_modified}_update=add={new_value}") == response + + +@patch(f"{FILE_PATH}.log_service_updated") +def test_service_update_logger_log_service_update( + mock_log_service_update: MagicMock, service_update_logger: ServiceUpdateLogger +) -> None: + # Arrange + service_update_logger.dos_logger = dos_logger_mock = MagicMock() + service_update_logger.dos_service = dos_service = MagicMock() + # Act + service_update_logger.log_service_update( + data_field_modified=EXAMPLE_DATA_FIELD_MODIFIED, + action=EXAMPLE_ACTION, + previous_value=EXAMPLE_PREVIOUS_VALUE, + new_value=EXAMPLE_NEW_VALUE, + ) + correlation_id = service_update_logger.correlation_id + # Assert + mock_log_service_update.assert_called_once_with( + action=EXAMPLE_ACTION, + data_field_modified=EXAMPLE_DATA_FIELD_MODIFIED, + new_value=f'"{EXAMPLE_NEW_VALUE}"', + previous_value=f'"{EXAMPLE_PREVIOUS_VALUE}"', + service_name=SERVICE_NAME, + service_uid=SERVICE_UID, + type_id=TYPE_ID, + dos_service=dos_service, + ) + dos_logger_mock.info.assert_called_once_with( + msg=f"{correlation_id}|{DOS_INTEGRATION_USER_NAME}|{NULL_VALUE}|{SERVICE_UID}|" + f"{SERVICE_NAME}|{TYPE_ID}|{EXAMPLE_DATA_FIELD_MODIFIED}|{EXAMPLE_ACTION}|" + f""""{EXAMPLE_PREVIOUS_VALUE}"|"{EXAMPLE_NEW_VALUE}"|{NULL_VALUE}|message=UpdateService|""" + f"correlationId={correlation_id}|elapsedTime={NULL_VALUE}|execution_time={NULL_VALUE}", + extra={"environment": "LOCAL"}, + ) + + +@patch(f"{FILE_PATH}.ServiceUpdateLogger.log_service_update") +@patch(f"{FILE_PATH}.ServiceUpdateLogger.get_opening_times_change") +@patch(f"{FILE_PATH}.opening_period_times_from_list") +def test_service_update_logger_log_standard_opening_times_service_update_for_weekday( + mock_opening_period_times_from_list: MagicMock, + mock_get_opening_times_change: MagicMock, + mock_log_service_update: MagicMock, + service_update_logger: ServiceUpdateLogger, +) -> None: + # Arrange + weekday = "monday" + mock_get_opening_times_change.return_value = (EXAMPLE_PREVIOUS_VALUE, EXAMPLE_NEW_VALUE) + # Act + service_update_logger.log_standard_opening_times_service_update_for_weekday( + data_field_modified=EXAMPLE_DATA_FIELD_MODIFIED, + action=EXAMPLE_ACTION, + previous_value=EXAMPLE_PREVIOUS_VALUE, + new_value=EXAMPLE_NEW_VALUE, + weekday=weekday, + ) + # Assert + mock_opening_period_times_from_list.assert_not_called() + mock_get_opening_times_change.assert_called_once_with( + EXAMPLE_DATA_FIELD_MODIFIED, + EXAMPLE_PREVIOUS_VALUE, + EXAMPLE_NEW_VALUE, + ) + mock_log_service_update.assert_called_once_with( + data_field_modified=EXAMPLE_DATA_FIELD_MODIFIED, + action=EXAMPLE_ACTION, + previous_value=EXAMPLE_PREVIOUS_VALUE, + new_value=EXAMPLE_NEW_VALUE, + ) + + +@patch(f"{FILE_PATH}.ServiceUpdateLogger.log_service_update") +@patch(f"{FILE_PATH}.ServiceUpdateLogger.get_opening_times_change") +def test_service_update_logger_log_specified_opening_times_service_update( + mock_get_opening_times_change: MagicMock, + mock_log_service_update: MagicMock, +) -> None: + # Arrange + service_update_logger = ServiceUpdateLogger( + service_uid=SERVICE_UID, + service_name=SERVICE_NAME, + type_id=TYPE_ID, + odscode=ODSCODE, + ) + open_periods = [ + OpenPeriod(time(1, 0, 0), time(2, 0, 0)), + OpenPeriod(time(3, 0, 0), time(5, 0, 0)), + OpenPeriod(time(8, 0, 0), time(12, 0, 0)), + ] + specified_opening_times = SpecifiedOpeningTime(open_periods, date(2022, 12, 26), True) + previous_value = [specified_opening_times] + new_value = [specified_opening_times] + expected_standard_opening_times_string = "2022-12-26-01:00-02:00,2022-12-26-03:00-05:00,2022-12-26-08:00-12:00" + mock_get_opening_times_change.return_value = (EXAMPLE_PREVIOUS_VALUE, EXAMPLE_NEW_VALUE) + # Act + service_update_logger.log_specified_opening_times_service_update(EXAMPLE_ACTION, previous_value, new_value) + # Assert + mock_get_opening_times_change.assert_called_once_with( + DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + expected_standard_opening_times_string, + expected_standard_opening_times_string, + ) + mock_log_service_update.assert_called_once_with( + data_field_modified=DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY, + action=EXAMPLE_ACTION, + previous_value=EXAMPLE_PREVIOUS_VALUE, + new_value=EXAMPLE_NEW_VALUE, + ) + + +@patch(f"{FILE_PATH}.ServiceUpdateLogger") +def test_log_service_updates_demographics_change(mock_service_update_logger: MagicMock) -> None: + # Arrange + changes_to_dos = MagicMock() + service_histories = MagicMock() + time_stamp = "1661499176" + change_key = "change_key" + service_history = { + time_stamp: { + "new": { + change_key: { + "changetype": EXAMPLE_ACTION, + "data": EXAMPLE_NEW_VALUE, + "area": "demographics", + "previous": EXAMPLE_PREVIOUS_VALUE, + }, + }, + }, + } + service_histories.service_history.keys.return_value = [time_stamp] + service_histories.service_history.__getitem__.return_value.__getitem__.return_value = service_history[time_stamp][ + "new" + ] + # Act + log_service_updates(changes_to_dos, service_histories) + # Assert + mock_service_update_logger.assert_called_once_with( + service_uid=str(changes_to_dos.dos_service.uid), + service_name=changes_to_dos.dos_service.name, + type_id=str(changes_to_dos.dos_service.typeid), + odscode=str(changes_to_dos.nhs_entity.odscode), + dos_service=changes_to_dos.dos_service, + ) + mock_service_update_logger.return_value.log_service_update.assert_called_once_with( + data_field_modified=change_key, + action=EXAMPLE_ACTION, + previous_value=EXAMPLE_PREVIOUS_VALUE, + new_value=EXAMPLE_NEW_VALUE, + ) + + +@patch(f"{FILE_PATH}.ServiceUpdateLogger") +def test_log_service_updates_standard_opening_times_change(mock_service_update_logger: MagicMock) -> None: + # Arrange + changes_to_dos = MagicMock() + service_histories = MagicMock() + time_stamp = "1661499176" + change_key = DOS_STANDARD_OPENING_TIMES_FRIDAY_CHANGE_KEY + service_history = { + time_stamp: { + "new": { + change_key: { + "changetype": EXAMPLE_ACTION, + "data": EXAMPLE_NEW_VALUE, + "area": "demographics", + "previous": EXAMPLE_PREVIOUS_VALUE, + }, + }, + }, + } + service_histories.service_history.keys.return_value = [time_stamp] + service_histories.service_history.__getitem__.return_value.__getitem__.return_value = service_history[time_stamp][ + "new" + ] + # Act + log_service_updates(changes_to_dos, service_histories) + # Assert + mock_service_update_logger.assert_called_once_with( + service_uid=str(changes_to_dos.dos_service.uid), + service_name=changes_to_dos.dos_service.name, + type_id=str(changes_to_dos.dos_service.typeid), + odscode=str(changes_to_dos.nhs_entity.odscode), + dos_service=changes_to_dos.dos_service, + ) + mock_service_update_logger.return_value.log_standard_opening_times_service_update_for_weekday.assert_called_once_with( + data_field_modified=change_key, + action=EXAMPLE_ACTION, + previous_value=changes_to_dos.dos_service.standard_opening_times, + new_value=changes_to_dos.nhs_entity.standard_opening_times, + weekday="friday", + ) + + +@patch(f"{FILE_PATH}.ServiceUpdateLogger") +def test_log_service_updates_specified_opening_times_change(mock_service_update_logger: MagicMock) -> None: + # Arrange + changes_to_dos = MagicMock() + service_histories = MagicMock() + time_stamp = "1661499176" + change_key = DOS_SPECIFIED_OPENING_TIMES_CHANGE_KEY + service_history = { + time_stamp: { + "new": { + change_key: { + "changetype": EXAMPLE_ACTION, + "data": EXAMPLE_NEW_VALUE, + "area": "demographics", + "previous": EXAMPLE_PREVIOUS_VALUE, + }, + }, + }, + } + service_histories.service_history.keys.return_value = [time_stamp] + service_histories.service_history.__getitem__.return_value.__getitem__.return_value = service_history[time_stamp][ + "new" + ] + # Act + log_service_updates(changes_to_dos, service_histories) + # Assert + mock_service_update_logger.assert_called_once_with( + service_uid=str(changes_to_dos.dos_service.uid), + service_name=changes_to_dos.dos_service.name, + type_id=str(changes_to_dos.dos_service.typeid), + odscode=str(changes_to_dos.nhs_entity.odscode), + dos_service=changes_to_dos.dos_service, + ) + mock_service_update_logger.return_value.log_specified_opening_times_service_update.assert_called_once_with( + action=EXAMPLE_ACTION, + previous_value=changes_to_dos.current_specified_opening_times, + new_value=changes_to_dos.new_specified_opening_times, + ) + + +@patch(f"{FILE_PATH}.ServiceUpdateLogger") +def test_log_service_updates_sgsdid_change(mock_service_update_logger: MagicMock) -> None: + # Arrange + changes_to_dos = MagicMock() + service_histories = MagicMock() + time_stamp = "1661499176" + change_key = DOS_SGSDID_CHANGE_KEY + service_history = { + time_stamp: { + "new": { + change_key: { + "changetype": "delete", + "data": {"remove": [DOS_PALLIATIVE_CARE_SGSDID]}, + "area": "clinical", + "previous": "", + }, + }, + }, + } + service_histories.service_history.keys.return_value = [time_stamp] + service_histories.service_history.__getitem__.return_value.__getitem__.return_value = service_history[time_stamp][ + "new" + ] + # Act + log_service_updates(changes_to_dos, service_histories) + # Assert + mock_service_update_logger.assert_called_once_with( + service_uid=str(changes_to_dos.dos_service.uid), + service_name=changes_to_dos.dos_service.name, + type_id=str(changes_to_dos.dos_service.typeid), + odscode=str(changes_to_dos.nhs_entity.odscode), + dos_service=changes_to_dos.dos_service, + ) + + mock_service_update_logger.return_value.log_sgsdid_service_update.assert_called_once_with( + action="delete", + new_value=DOS_PALLIATIVE_CARE_SGSDID, + ) diff --git a/application/slack_messenger/requirements.txt b/application/slack_messenger/requirements.txt index e08d19389..589fc68bf 100644 --- a/application/slack_messenger/requirements.txt +++ b/application/slack_messenger/requirements.txt @@ -1,2 +1,2 @@ -aws-lambda-powertools +aws-lambda-powertools[tracer] ~= 2.43.0 requests diff --git a/application/slack_messenger/slack_messenger.py b/application/slack_messenger/slack_messenger.py index 1e1353d51..db6b8028a 100644 --- a/application/slack_messenger/slack_messenger.py +++ b/application/slack_messenger/slack_messenger.py @@ -1,166 +1,128 @@ +from datetime import datetime from json import loads from os import environ -from typing import Any, Dict, TypedDict -from requests import post +from typing import Any from urllib.parse import quote -from datetime import datetime, timedelta -from aws_lambda_powertools import Logger, Tracer + +from aws_lambda_powertools.logging import Logger +from aws_lambda_powertools.tracing import Tracer from aws_lambda_powertools.utilities.data_classes import SNSEvent, event_source from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext -from common.constants import METRIC_REPORT_KEY_MAP -from common.middlewares import unhandled_exception_logging +from requests import post +from common.middlewares import unhandled_exception_logging logger = Logger() tracer = Tracer() -def get_message_for_cloudwatch_event(event: SNSEvent) -> Dict[str, Any]: +@unhandled_exception_logging() +@tracer.capture_lambda_handler() +@event_source(data_class=SNSEvent) +@logger.inject_lambda_context(clear_state=True) +def lambda_handler(event: SNSEvent, _context: LambdaContext) -> None: + """Entrypoint handler for the slack_messenger lambda. + + Args: + event (SNSEvent): SNS event + context (LambdaContext): Lambda function context object + + Event: The event payload + + Some code may need to be changed if the exact input format is changed. + """ + message = get_message_for_cloudwatch_event(event) + logger.info("Sending alert to slack.", slack_message=message) + send_msg_slack(message) + + +def get_message_for_cloudwatch_event(event: SNSEvent) -> dict[str, Any]: + """Get message for CloudWatch event. + + Args: + event (SNSEvent): SNS event + Returns: + dict[str, Any]: Message for slack + """ + # Get Event record = next(event.records) timestamp = datetime.strptime(record.sns.timestamp, "%Y-%m-%dT%H:%M:%S.%fZ").timestamp() message = loads(record.sns.message) + environment = environ["SHARED_ENVIRONMENT"] + # Get Alarm Info region = record.event_subscription_arn.split(":")[3] alarm_name: str = message["AlarmName"] - - metric_name = message["Trigger"]["MetricName"] - namespace = str(message["Trigger"]["Namespace"]).lower() - filter_env = list(filter(lambda s: s["name"] == "ENV", message["Trigger"]["Dimensions"])) - env = filter_env[0]["value"] if len(filter_env) > 0 else "" - new_state = message["NewStateValue"] - alarm_description = message["AlarmDescription"] - trigger = message["Trigger"] - project_id = f"{namespace}-{env}" - log_groups = [ - f"{project_id}-event-processor", - f"{project_id}-event-sender", - f"{project_id}-cr-fifo-dlq-handler", - f"{project_id}-fifo-dlq-handler", - ] - filters = {"report_key": METRIC_REPORT_KEY_MAP.get(metric_name, "")} - color = "warning" - - if message["NewStateValue"] == "ALARM": - color = "#e01e5a" - elif message["NewStateValue"] == "OK": - color = "good" + new_state = message.get("NewStateValue", "") + alarm_description = message.get("AlarmDescription", "") link = ( "https://console.aws.amazon.com/cloudwatch/home" f"?region={region}#alarm:alarmFilter=ANY;name={quote(alarm_name.encode('utf-8'))}" ) - slack_message = { + cloudwatch_dashboard_link = ( + f"https://{region}.console.aws.amazon.com/cloudwatch/home?region={region}" + f"#dashboards/dashboard/uec-dos-int-{environment}-monitoring-dashboard" + ) + splunk_dashboard_link = "https://nhsdigital.splunkcloud.com/en-GB/app/nhsd_uec_pu_all_sh_all_viz/dos_integration_test_monitoring__update_request_summary_dashboard" + + emoji = ":white_check_mark:" if new_state == "OK" else ":rotating_light:" + logger.append_keys(alarm_name=alarm_name, alarm_description=alarm_description) + short_name = alarm_name.split("|")[2] + + return { "blocks": [ - {"type": "section", "text": {"type": "mrkdwn", "text": f":rotating_light: *<{link}|{alarm_name}>*"}} + { + "type": "header", + "text": { + "type": "plain_text", + "text": f"{emoji} {short_name}", + "emoji": True, + }, + }, ], "attachments": [ { - "color": color, + "color": get_alarm_colour(new_state), "fields": [ - {"title": "Alarm Name", "value": alarm_name, "short": True}, - {"title": "Alarm State", "value": new_state, "short": True}, - {"title": "Alarm Description", "value": alarm_description, "short": False}, { - "title": "Trigger", - "value": f"{trigger['Statistic']} {metric_name} {trigger['ComparisonOperator']} " - f"{str(trigger['Threshold'])} for {str(trigger['EvaluationPeriods'])} period(s) " - f" of {str(trigger['Period'])} seconds.", + "value": ( + f"*Name*: <{link}|{alarm_name}> | *State*: {new_state.capitalize()}\n" + f"*Description*: {alarm_description}\n" + f"<{cloudwatch_dashboard_link}|CloudWatch Monitoring Dashboard> | " + f"<{splunk_dashboard_link}|Splunk Dashboard>" + ), "short": False, }, - { - "title": "", - "value": f"<{generate_aws_cloudwatch_log_insights_url(region, log_groups, filters)}|View Logs>", - }, ], "ts": timestamp, - } + }, ], } - return slack_message -def send_msg_slack(message): - url = environ["SLACK_WEBHOOK_URL"] - channel = environ["SLACK_ALERT_CHANNEL"] - headers: Dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"} - - message["channel"] = channel - message["icon_emoji"] = "" - - resp = post( - url=url, - headers=headers, - json=message, - ) - logger.info( - "Message sent to slack", - extra={"slack_message": message, "status_code": resp.status_code, "response": resp.text}, - ) +def get_alarm_colour(new_state: str) -> str: + """Get alarm colour. + Args: + new_state (str): New state of the alarm -def generate_aws_cloudwatch_log_insights_url(region: str, log_groups: list, filters: dict, limit: int = 100): - def quote_string(input_str): - return f"""{quote(input_str, safe="~()'*").replace('%', '*')}""" - - def quote_list(input_list): - quoted_list = "" - for item in input_list: - if isinstance(item, str): - item = f"'{item}" - quoted_list += f"~{item}" - return f"({quoted_list})" - - params = [] - - fields = "fields @timestamp,correlation_id,ods_code,level,message_received,function_name, message" - query_filters = "\n".join([f'| filter {k}="{v}"' for (k, v) in filters.items()]) - query = f"{fields}\n{query_filters}\n| sort @timestamp asc\n| limit {limit}" - - parameters: TypedDict = { - "end": datetime.utcnow().isoformat(timespec="milliseconds") + "Z", - "start": (datetime.utcnow() - timedelta(hours=1)).isoformat(timespec="milliseconds") + "Z", - "unit": "seconds", - "timeType": "ABSOLUTE", # "ABSOLUTE", # OR RELATIVE and end = 0 and start is negative seconds - "tz": "Local", # OR "UTC" - "editorString": query, - "isLiveTail": False, - "source": [f"/aws/lambda/{lg}" for lg in log_groups], - } + Returns: + str: Color of the alarm + """ + if new_state == "ALARM": + return "#e01e5a" + return "good" if new_state == "OK" else "warning" - for key, value in parameters.items(): - if key == "editorString": - value = "'" + quote(value) - value = value.replace("%", "*") - elif isinstance(value, str): - value = "'" + value - if isinstance(value, bool): - value = str(value).lower() - elif isinstance(value, list): - value = quote_list(value) - params += [key, str(value)] - - object_string = quote_string("~(" + "~".join(params) + ")") - scaped_object = quote(object_string, safe="*").replace("~", "%7E") - with_query_detail = "?queryDetail=" + scaped_object - result = quote(with_query_detail, safe="*").replace("%", "$") - return f"https://{region}.console.aws.amazon.com/cloudwatch/home?region={region}#logsV2:logs-insights{result}" - -@unhandled_exception_logging() -@tracer.capture_lambda_handler() -@event_source(data_class=SNSEvent) -@logger.inject_lambda_context -def lambda_handler(event: SNSEvent, context: LambdaContext) -> None: - """Entrypoint handler for the slack_messenger lambda +def send_msg_slack(message: dict[str, Any]) -> None: + """Send message to slack. Args: - event (SNSEvent): - context (LambdaContext): Lambda function context object - - Event: The event payload - - Some code may need to be changed if the exact input format is changed. + message (dict[str, Any]): Message to send to slack """ - - message = get_message_for_cloudwatch_event(event) - logger.info("Sending alert to slack.", extra={"slack_message": message}) - send_msg_slack(message) + url = environ["SLACK_WEBHOOK_URL"] + channel = environ["SLACK_ALERT_CHANNEL"] + headers: dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"} + message["channel"] = channel + resp = post(url=url, headers=headers, json=message, timeout=5) + logger.info("Message sent to slack", slack_message=message, status_code=resp.status_code, response=resp.text) diff --git a/build/docker/dos-api-gateway/assets/.gitkeep b/application/slack_messenger/tests/__init__.py similarity index 100% rename from build/docker/dos-api-gateway/assets/.gitkeep rename to application/slack_messenger/tests/__init__.py diff --git a/application/slack_messenger/tests/test_slack_messenger.py b/application/slack_messenger/tests/test_slack_messenger.py index dd6793510..4a030af6e 100644 --- a/application/slack_messenger/tests/test_slack_messenger.py +++ b/application/slack_messenger/tests/test_slack_messenger.py @@ -1,40 +1,23 @@ -from dataclasses import dataclass from json import dumps from os import environ +from unittest.mock import MagicMock, patch + +import pytest from aws_lambda_powertools.utilities.data_classes import SNSEvent -from pytest import fixture, raises -from application.slack_messenger.slack_messenger import ( - lambda_handler, - send_msg_slack, - get_message_for_cloudwatch_event, - generate_aws_cloudwatch_log_insights_url, -) -from common.constants import INVALID_POSTCODE_REPORT_ID, METRIC_REPORT_KEY_MAP -from unittest.mock import patch +from aws_lambda_powertools.utilities.typing import LambdaContext +from application.slack_messenger.slack_messenger import get_message_for_cloudwatch_event, lambda_handler, send_msg_slack FILE_PATH = "application.slack_messenger.slack_messenger" -@fixture -def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "slack-messenger" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:slack-messenger" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" - - return LambdaContext() - - LAMBDA_INVOKE_RESPONSE = { "Payload": "", "StatusCode": 202, "ResponseMetadata": {}, } MESSAGE = { - "AlarmName": "Invalid Postcodes Test", + "AlarmName": "uec-dos-int | test |Invalid Postcodes Test", "AlarmDescription": "Testing alarm for invalid postcodes", "AWSAccountId": "000000000000", "AlarmConfigurationUpdatedTimestamp": "2022-02-22T12:22:09.734+0000", @@ -46,11 +29,11 @@ class LambdaContext: "OldStateValue": "INSUFFICIENT_DATA", "Trigger": { "MetricName": "InvalidPostcode", - "Namespace": "UEC-DOS-INT", + "Namespace": "uec-dos-int", "StatisticType": "Statistic", "Statistic": "SUM", "Unit": None, - "Dimensions": [{"value": "di-259", "name": "ENV"}], + "Dimensions": [{"value": "ds-259", "name": "ENV"}], "Period": 300, "EvaluationPeriods": 1, "DatapointsToAlarm": 1, @@ -69,7 +52,7 @@ class LambdaContext: "Sns": { "Type": "Notification", "MessageId": "be6ed8fb-6ca6-5ecb-ba78-82609035c3ad", - "TopicArn": "arn:aws:sns:eu-west-2:000000000000:uec-dos-int-di-259-topic-app-alerts-for-slack", + "TopicArn": "arn:aws:sns:eu-west-2:000000000000:uec-dos-int-ds-259-topic-app-alerts-for-slack", "Subject": 'ALARM: "Invalid Postcodes Test" in EU (London)', "Message": dumps(MESSAGE), "Timestamp": "2022-03-04T11:38:59.038Z", @@ -79,15 +62,18 @@ class LambdaContext: "UnsubscribeUrl": "whocares", "MessageAttributes": {}, }, - } - ] + }, + ], } +WEBHOOK_URL = "https://hooks.slack.com/services/1/2/3" + @patch(f"{FILE_PATH}.get_message_for_cloudwatch_event") @patch(f"{FILE_PATH}.send_msg_slack") -def test_lambda_handler_slack_messenger(mock_send, mock_get, lambda_context): - +def test_lambda_handler_slack_messenger( + mock_send: MagicMock, mock_get: MagicMock, lambda_context: LambdaContext +) -> None: expected = {"somefield": "somevalue"} mock_send.return_value = None mock_get.return_value = expected @@ -101,98 +87,81 @@ def test_lambda_handler_slack_messenger(mock_send, mock_get, lambda_context): mock_send.assert_called_once_with(expected) -def test_send_message_missing_url(lambda_context): +def test_send_message_missing_url() -> None: message = {} # Act - with raises(KeyError): + with pytest.raises(KeyError): send_msg_slack(message) -def test_send_message_url_no_channel(lambda_context): +def test_send_message_url_no_channel() -> None: + # Arrange message = {} - environ["SLACK_WEBHOOK_URL"] = "www.someexamplethingy.com" - # Act - with raises(KeyError): + environ["SLACK_WEBHOOK_URL"] = WEBHOOK_URL + # Act & Assert + with pytest.raises(KeyError): send_msg_slack(message) + # Clean Up + del environ["SLACK_WEBHOOK_URL"] @patch(f"{FILE_PATH}.post") -def test_send_message(mock_post, lambda_context): - +def test_send_message(mock_post: MagicMock) -> None: + # Arrange message = {"text": "hello dave"} - environ["SLACK_WEBHOOK_URL"] = "www.someexamplethingy.com" + environ["SLACK_WEBHOOK_URL"] = WEBHOOK_URL environ["SLACK_ALERT_CHANNEL"] = "channel5" # Act send_msg_slack(message) + # Assert mock_post.assert_called_once_with( - url="www.someexamplethingy.com", + url=WEBHOOK_URL, headers={"Content-Type": "application/json", "Accept": "application/json"}, - json={"text": "hello dave", "channel": "channel5", "icon_emoji": ""}, + json={"text": "hello dave", "channel": "channel5"}, + timeout=5, ) + # Clean Up + del environ["SLACK_WEBHOOK_URL"] -@patch(f"{FILE_PATH}.generate_aws_cloudwatch_log_insights_url") -def test_get_messsage_from_event(mock_cloudwatch_url): - +@pytest.mark.parametrize( + ("new_state_value", "colour", "emoji"), + [ + ("ALARM", "#e01e5a", ":rotating_light:"), + ("OK", "good", ":white_check_mark:"), + ("INSUFFICIENT_DATA", "warning", ":rotating_light:"), + ], +) +def test_get_message_from_event(new_state_value: str, colour: str, emoji: str) -> None: # Arrange + environ["SHARED_ENVIRONMENT"] = "test" sns_event_dict = SNS_EVENT.copy() + message = MESSAGE.copy() + message["NewStateValue"] = new_state_value + sns_event_dict["Records"][0]["Sns"]["Message"] = dumps(message) sns_event = SNSEvent(sns_event_dict) - mock_cloudwatch_url.return_value = "https://test.com" # Act message = get_message_for_cloudwatch_event(sns_event) - + # Assert assert message == { "attachments": [ { - "color": "#e01e5a", + "color": colour, "fields": [ - { - "short": True, - "title": "Alarm Name", - "value": "Invalid Postcodes Test", - }, - { - "short": True, - "title": "Alarm State", - "value": "ALARM", - }, - { - "short": False, - "title": "Alarm Description", - "value": "Testing alarm for invalid postcodes", - }, { "short": False, - "title": "Trigger", - "value": "SUM InvalidPostcode GreaterThanThreshold 0.0 for 1 period(s) of 300 seconds.", + "value": f"*Name*: | *State*: {new_state_value.capitalize()}\n*Description*: Testing alarm for invalid postcodes\n | ", # noqa: E501 }, - {"title": "", "value": ""}, ], "ts": 1646393939.038, }, ], "blocks": [ { - "text": { - "text": ":rotating_light: **", - "type": "mrkdwn", - }, - "type": "section", + "text": {"emoji": True, "text": f"{emoji} Invalid Postcodes Test", "type": "plain_text"}, + "type": "header", }, ], } - - -def test_generate_cloudwatch_url(): - project_id = "test-service-name" - region = "eu-west-2" - metric_name = "InvalidPostcode" - report_key = METRIC_REPORT_KEY_MAP.get(metric_name, "") - log_groups = [f"{project_id}-event-processor"] - filters = {"report_key": report_key} - expected_url = "https://eu-west-2.console.aws.amazon.com/cloudwatch/home?region=eu-west-2#logsV2" - url = generate_aws_cloudwatch_log_insights_url(region, log_groups, filters, 10) - assert report_key == INVALID_POSTCODE_REPORT_ID - assert log_groups == ["test-service-name-event-processor"] - assert url.startswith(expected_url) + # Clean Up + del environ["SHARED_ENVIRONMENT"] diff --git a/application/test_db_checker_handler/requirements.txt b/application/test_db_checker_handler/requirements.txt deleted file mode 100644 index 84e6e8936..000000000 --- a/application/test_db_checker_handler/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -aws-lambda-powertools -psycopg2-binary diff --git a/application/test_db_checker_handler/test_db_checker_handler.py b/application/test_db_checker_handler/test_db_checker_handler.py deleted file mode 100644 index 1bab1f30e..000000000 --- a/application/test_db_checker_handler/test_db_checker_handler.py +++ /dev/null @@ -1,158 +0,0 @@ -from json import dumps -from typing import Any, Dict - -from aws_lambda_powertools import Logger, Tracer -from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext -from common.dos import ( - VALID_STATUS_ID, - SpecifiedOpeningTime, - get_specified_opening_times_from_db, - get_standard_opening_times_from_db, -) -from common.dos_db_connection import query_dos_db -from common.middlewares import unhandled_exception_logging -from common.service_type import get_valid_service_types - -tracer = Tracer() -logger = Logger() - - -@unhandled_exception_logging() -@tracer.capture_lambda_handler() -@logger.inject_lambda_context() -def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> str: - """Entrypoint handler for the lambda - - Args: - event (Dict[str, Any]): Lambda function invocation event - context (LambdaContext): Lambda function context object - """ - request = event - result = None - if request["type"] == "get_pharmacy_odscodes": - type_id_query = get_valid_service_types_equals_string("PHA") - query = ( - f"SELECT LEFT(odscode, 5) FROM services WHERE typeid {type_id_query} " - f"AND statusid = {VALID_STATUS_ID} AND odscode IS NOT NULL" - ) - result = run_query(query, None) - elif request["type"] == "get_single_pharmacy_service_odscode": - type_id_query = get_valid_service_types_equals_string("PHA") - query = ( - f"SELECT LEFT(odscode,5) FROM services WHERE typeid {type_id_query} " - f"AND statusid = {VALID_STATUS_ID} AND odscode IS NOT NULL AND RIGHT(address, 1) != '$' " - "AND LENGTH(LEFT(odscode,5)) = 5 GROUP BY LEFT(odscode,5) HAVING COUNT(LEFT(odscode,5)) = 1" - ) - result = run_query(query, None) - elif request["type"] == "get_dentist_odscodes": - type_id_query = get_valid_service_types_equals_string("Dentist") - query = ( - f"SELECT odscode FROM services WHERE typeid {type_id_query} " - f"AND statusid = {VALID_STATUS_ID} AND odscode IS NOT NULL AND LENGTH(odscode) = 6 AND LEFT(odscode, 1)='V'" - ) - result = run_query(query, None) - elif request["type"] == "get_changes": - cid = request.get("correlation_id") - if cid is not None: - query = f"SELECT value from changes where externalref = '{cid}'" - result = run_query(query, None) - else: - raise ValueError("Missing correlation id") - elif request["type"] == "get_service_type_from_cr": - sid = request.get("get_service_id") - if sid is not None: - query = f"SELECT typeid from services where id = '{sid}'" - result = run_query(query, None) - else: - raise ValueError("Missing correlation id") - elif request["type"] == "get_service_id": - cid = request.get("correlation_id") - if cid is not None: - query = f"SELECT serviceid from changes where externalref = '{cid}'" - result = run_query(query, None) - else: - raise ValueError("Missing correlation id") - elif request["type"] == "get_approver_status": - cid = request.get("correlation_id") - if cid is not None: - query = f"SELECT modifiedtimestamp from changes where approvestatus = 'COMPLETE' and externalref = '{cid}'" - result = run_query(query, None) - else: - raise ValueError("Missing correlation id") - elif request["type"] == "change_event_demographics": - odscode = request.get("odscode") - organisation_type_id = request.get("organisation_type_id") - if odscode is not None and organisation_type_id is not None: - type_id_query = get_valid_service_types_equals_string(organisation_type_id) - db_columns = ( - "id", - "name", - "odscode", - "address", - "postcode", - "web", - "typeid", - "statusid", - "publicphone", - "publicname", - ) - query = ( - f"SELECT {', '.join(db_columns)} " - f"FROM services WHERE odscode like %(ODSCODE)s AND typeid {type_id_query} " - "AND statusid = %(VALID_STATUS_ID)s AND odscode IS NOT NULL" - ) - query_vars = { - "ODSCODE": f"{odscode}%", - "VALID_STATUS_ID": VALID_STATUS_ID, - } - query_results = run_query(query, query_vars) - if len(query_results) > 0: - query_results = query_results[0] - result = dict(zip(db_columns, query_results)) - else: - raise ValueError(f"No matching services for odscode {odscode}") - else: - raise ValueError("Missing odscode") - elif request["type"] == "change_event_standard_opening_times": - service_id = request.get("service_id") - if service_id is not None: - standard_opening_times = get_standard_opening_times_from_db(service_id) - result = standard_opening_times.export_cr_format() - else: - raise ValueError("Missing service_id") - elif request["type"] == "change_event_specified_opening_times": - service_id = request.get("service_id") - if service_id is not None: - specified_opening_times = get_specified_opening_times_from_db(service_id) - result = SpecifiedOpeningTime.export_cr_format_list(specified_opening_times) - else: - raise ValueError("Missing service_id") - else: - raise ValueError("Unsupported request") - return dumps(result, default=str) - - -def run_query(query, query_vars) -> list: - logger.info("Running query", extra={"query": query}) - cursor = query_dos_db(query, query_vars) - query_result = cursor.fetchall() - cursor.close() - return query_result - - -def get_valid_service_types_equals_string(organisation_type_id: str) -> str: - """Gets a query string for to match valid dos service type id/ids - - Args: - organisation_type_id (str): Organsation type id - - Returns: - str: Equals string to include in query - """ - valid_service_types: list = get_valid_service_types(organisation_type_id) - if len(valid_service_types) > 1: - valid_service_types = tuple(valid_service_types) - type_id_query = f"IN {valid_service_types}" - else: - type_id_query = f"= {valid_service_types[0]}" - return type_id_query diff --git a/application/test_db_checker_handler/tests/test_test_db_checker_handler.py b/application/test_db_checker_handler/tests/test_test_db_checker_handler.py deleted file mode 100644 index 7908b7839..000000000 --- a/application/test_db_checker_handler/tests/test_test_db_checker_handler.py +++ /dev/null @@ -1,203 +0,0 @@ -from dataclasses import dataclass -from json import dumps -from unittest.mock import patch - -from pytest import fixture, raises - -from ..test_db_checker_handler import lambda_handler - -FILE_PATH = "application.test_db_checker_handler.test_db_checker_handler" - - -@fixture -def lambda_context(): - @dataclass - class LambdaContext: - function_name: str = "test_db_checker" - memory_limit_in_mb: int = 128 - invoked_function_arn: str = "arn:aws:lambda:eu-west-1:809313241:function:test_db_checker-handler" - aws_request_id: str = "52fdfc07-2182-154f-163f-5f0f9a621d72" - - return LambdaContext() - - -@patch(f"{FILE_PATH}.run_query") -def test_type_get_pharmacy_odscodes(mock_run_query, lambda_context): - # Arrange - mock_run_query.return_value = [("ODS12"), ("ODS11")] - test_input = {"type": "get_pharmacy_odscodes"} - # Act - response = lambda_handler(test_input, lambda_context) - # Assert - mock_run_query.assert_called_once_with( - "SELECT LEFT(odscode, 5) FROM services WHERE typeid IN (13, 131, 132, 134, 137) " - "AND statusid = 1 AND odscode IS NOT NULL", - None, - ) - assert response == '["ODS12", "ODS11"]' - - -@patch(f"{FILE_PATH}.run_query") -def test_type_get_single_pharmacy_service_odscode(mock_run_query, lambda_context): - # Arrange - mock_run_query.return_value = [("ODS12"), ("ODS11")] - test_input = {"type": "get_single_pharmacy_service_odscode"} - # Act - response = lambda_handler(test_input, lambda_context) - # Assert - mock_run_query.assert_called_once_with( - "SELECT LEFT(odscode,5) FROM services WHERE typeid IN (13, 131, 132, 134, 137) " - "AND statusid = 1 AND odscode IS NOT NULL AND RIGHT(address, 1) != '$' AND LENGTH(LEFT(odscode,5)) = 5 " - "GROUP BY LEFT(odscode,5) HAVING COUNT(LEFT(odscode,5)) = 1", - None, - ) - assert response == '["ODS12", "ODS11"]' - - -@patch(f"{FILE_PATH}.run_query") -def test_type_get_dentist_odscodes(mock_run_query, lambda_context): - # Arrange - mock_run_query.return_value = [("ODS12"), ("ODS11")] - test_input = {"type": "get_dentist_odscodes"} - # Act - response = lambda_handler(test_input, lambda_context) - # Assert - mock_run_query.assert_called_once_with( - "SELECT odscode FROM services WHERE typeid = 12 " - "AND statusid = 1 AND odscode IS NOT NULL AND LENGTH(odscode) = 6 AND LEFT(odscode, 1)='V'", - None, - ) - assert response == '["ODS12", "ODS11"]' - - -@patch(f"{FILE_PATH}.run_query") -def test_type_get_changes_with_id(mock_run_query, lambda_context): - # Arrange - expected = { - "new": { - "cmstelephoneno": { - "changetype": "modify", - "data": "0118 999 88199 9119 725 3", - "area": "demographic", - "previous": "0208 882 1081", - } - }, - "initiator": {"userid": "CHANGE_REQUEST", "timestamp": "2022-01-27 10:13:50"}, - } - mock_run_query.return_value = expected - - test_input = {"type": "get_changes", "correlation_id": "dave"} - # Act - response = lambda_handler(test_input, lambda_context) - # Assert - mock_run_query.assert_called_once_with("SELECT value from changes where externalref = 'dave'", None) - expected_output = dumps(expected) - assert response == expected_output - - -@patch(f"{FILE_PATH}.run_query") -def test_type_get_changes_no_id(mock_run_query, lambda_context): - # Arrange - test_input = {"type": "get_changes"} - # Act - with raises(ValueError) as err: - lambda_handler(test_input, lambda_context) - # Assert - assert str(err.value) == "Missing correlation id" - mock_run_query.assert_not_called() - - -@patch(f"{FILE_PATH}.run_query") -def test_get_demographics_no_match(mock_run_query, lambda_context): - # Arrange - odscode = "FA100" - test_input = {"type": "change_event_demographics", "odscode": odscode, "organisation_type_id": "PHA"} - mock_run_query.return_value = [] - with raises(ValueError) as err: - lambda_handler(test_input, lambda_context) - # Assert - mock_run_query.assert_called_once_with( - "SELECT id, name, odscode, address, postcode, web, typeid, statusid, publicphone, " - "publicname FROM services WHERE odscode like %(ODSCODE)s AND typeid IN (13, 131, 132, 134, 137) " - "AND statusid = %(VALID_STATUS_ID)s AND odscode IS NOT NULL", - {"ODSCODE": f"{odscode}%", "VALID_STATUS_ID": 1}, - ) - assert str(err.value) == f"No matching services for odscode {odscode}" - - -@patch(f"{FILE_PATH}.run_query") -def test_type_demographics(mock_run_query, lambda_context): - # Arrange - odscode = "FA100" - expected = { - "id": "1", - "name": "Example", - "odscode": "FA100", - "address": "5-7 Kingsway$testown", - "postcode": "BD16 4RP", - "web": None, - "typeid": 131, - "statusid": 1, - "publicphone": None, - "publicname": None, - } - test_input = {"type": "change_event_demographics", "odscode": odscode, "organisation_type_id": "PHA"} - mock_run_query.return_value = [list(expected.values())] - # Act - response = lambda_handler(test_input, lambda_context) - # Assert - mock_run_query.assert_called_once_with( - "SELECT id, name, odscode, address, postcode, web, typeid, statusid, publicphone, " - "publicname FROM services WHERE odscode like %(ODSCODE)s AND typeid IN (13, 131, 132, 134, 137) " - "AND statusid = %(VALID_STATUS_ID)s AND odscode IS NOT NULL", - {"ODSCODE": f"{odscode}%", "VALID_STATUS_ID": 1}, - ) - assert response == dumps(expected) - - -@patch(f"{FILE_PATH}.run_query") -def test_type_demographics_no_ods(matching_dos_mock, lambda_context): - # Arrange - test_input = {"type": "change_event_demographics"} - # Act - with raises(ValueError) as err: - lambda_handler(test_input, lambda_context) - # Assert - assert str(err.value) == "Missing odscode" - matching_dos_mock.assert_not_called() - - -@patch(f"{FILE_PATH}.get_standard_opening_times_from_db") -def test_type_standards_no_ods(mock_opening_times, lambda_context): - # Arrange - test_input = {"type": "change_event_standard_opening_times"} - # Act - with raises(ValueError) as err: - lambda_handler(test_input, lambda_context) - # Assert - assert str(err.value) == "Missing service_id" - mock_opening_times.assert_not_called() - - -@patch(f"{FILE_PATH}.get_specified_opening_times_from_db") -def test_type_specifieds_no_ods(mock_opening_times, lambda_context): - # Arrange - test_input = {"type": "change_event_specified_opening_times"} - # Act - with raises(ValueError) as err: - lambda_handler(test_input, lambda_context) - # Assert - assert str(err.value) == "Missing service_id" - mock_opening_times.assert_not_called() - - -@patch(f"{FILE_PATH}.query_dos_db") -def test_type_change_unknown_type(query_dos_mock, lambda_context): - # Arrange - test_input = {"type": "dave"} - # Act - with raises(ValueError) as err: - lambda_handler(test_input, lambda_context) - # Assert - assert str(err.value) == "Unsupported request" - query_dos_mock.assert_not_called() diff --git a/application/test_resources/STANDARD_EVENT.json b/application/test_resources/STANDARD_EVENT.json new file mode 100644 index 000000000..df53f5893 --- /dev/null +++ b/application/test_resources/STANDARD_EVENT.json @@ -0,0 +1,184 @@ +{ + "ODSCode": "TES73", + "OrganisationName": "Fake Pharmacy", + "OrganisationTypeId": "PHA", + "OrganisationType": "Pharmacy", + "OrganisationSubType": "Community", + "OrganisationStatus": "Visible", + "Address1": "Flat 619", + "Address2": "62 Fake Street", + "Address3": "Hazel Grove", + "City": "Bath", + "County": "Somerset", + "Postcode": "TE5 7ER", + "ParentOrganisation": { + "ODSCode": "TES", + "OrganisationName": "Fake Pharmacy Corperation" + }, + "Contacts": [ + { + "ContactType": "Primary", + "ContactAvailabilityType": "Office hours", + "ContactMethodType": "Website", + "ContactValue": "http://www.FakePharmacy.co.uk/" + }, + { + "ContactType": "Primary", + "ContactAvailabilityType": "Office hours", + "ContactMethodType": "Telephone", + "ContactValue": "01234 567890" + } + ], + "OpeningTimes": [ + { + "Weekday": "Monday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Monday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Tuesday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Tuesday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Wednesday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Wednesday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Thursday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Thursday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Friday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Friday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Saturday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 720, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "", + "OpeningTime": "", + "ClosingTime": "", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": "Dec 24 2021", + "IsOpen": false + }, + { + "Weekday": "", + "OpeningTime": "", + "ClosingTime": "", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": "Dec 25 2021", + "IsOpen": false + }, + { + "Weekday": "", + "OpeningTime": "", + "ClosingTime": "", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": "Dec 26 2021", + "IsOpen": false + }, + { + "Weekday": "", + "OpeningTime": "", + "ClosingTime": "", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": "Dec 27 2021", + "IsOpen": false + } + ] +} diff --git a/application/test_resources/STANDARD_EVENT_WITH_STAFF.json b/application/test_resources/STANDARD_EVENT_WITH_STAFF.json new file mode 100644 index 000000000..1cdab418c --- /dev/null +++ b/application/test_resources/STANDARD_EVENT_WITH_STAFF.json @@ -0,0 +1,200 @@ +{ + "ODSCode": "TES73", + "OrganisationName": "Fake Pharmacy", + "OrganisationTypeId": "PHA", + "OrganisationType": "Pharmacy", + "OrganisationSubType": "Community", + "OrganisationStatus": "Visible", + "Address1": "Flat 619", + "Address2": "62 Fake Street", + "Address3": "Hazel Grove", + "City": "Bath", + "County": "Somerset", + "Postcode": "TE5 7ER", + "ParentOrganisation": { + "ODSCode": "TES", + "OrganisationName": "Fake Pharmacy Corperation" + }, + "Contacts": [ + { + "ContactType": "Primary", + "ContactAvailabilityType": "Office hours", + "ContactMethodType": "Website", + "ContactValue": "http://www.FakePharmacy.co.uk/" + }, + { + "ContactType": "Primary", + "ContactAvailabilityType": "Office hours", + "ContactMethodType": "Telephone", + "ContactValue": "01234 567890" + } + ], + "Staff": [ + { + "Title": "Mr", + "GivenName": "Dave", + "FamilyName": "Davies", + "Role": "Stub", + "Qualification": "Unit" + }, + { + "Title": "Mr", + "GivenName": "Dummy", + "FamilyName": "Stubb", + "Role": "Tester", + "Qualification": "" + } + ], + "OpeningTimes": [ + { + "Weekday": "Monday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Monday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Tuesday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Tuesday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Wednesday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Wednesday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Thursday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Thursday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Friday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 780, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Friday", + "OpeningTime": "14:00", + "ClosingTime": "17:30", + "OffsetOpeningTime": 840, + "OffsetClosingTime": 1050, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "Saturday", + "OpeningTime": "09:00", + "ClosingTime": "13:00", + "OffsetOpeningTime": 540, + "OffsetClosingTime": 720, + "OpeningTimeType": "General", + "AdditionalOpeningDate": "", + "IsOpen": true + }, + { + "Weekday": "", + "OpeningTime": "", + "ClosingTime": "", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": "Dec 24 2021", + "IsOpen": false + }, + { + "Weekday": "", + "OpeningTime": "", + "ClosingTime": "", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": "Dec 25 2021", + "IsOpen": false + }, + { + "Weekday": "", + "OpeningTime": "", + "ClosingTime": "", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": "Dec 26 2021", + "IsOpen": false + }, + { + "Weekday": "", + "OpeningTime": "", + "ClosingTime": "", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": "Dec 27 2021", + "IsOpen": false + } + ] +} diff --git a/build/automation/VERSION b/build/automation/VERSION index 0f80922c5..af57a7fc4 100644 --- a/build/automation/VERSION +++ b/build/automation/VERSION @@ -1 +1 @@ -20211010103151-6b3f50e-0.8.0 +20220802121359-9f40dc4-snapshot diff --git a/build/automation/bin/git-secrets b/build/automation/bin/git-secrets index 60e2ddefa..8cf94c365 100755 --- a/build/automation/bin/git-secrets +++ b/build/automation/bin/git-secrets @@ -185,7 +185,7 @@ prepare_commit_msg_hook() { merge,) local git_head=$(env | grep GITHEAD) # e.g. GITHEAD_=release/1.43 local sha="${git_head##*=}" # Get just the SHA - local branch=$(git symbolic-ref HEAD) # e.g. refs/heads/master + local branch=$(git symbolic-ref HEAD) # e.g. refs/heads/main local dest="${branch#refs/heads/}" # cut out "refs/heads" git log "${dest}".."${sha}" -p | scan_with_fn_or_die "scan" - ;; diff --git a/build/automation/bin/update-ip-address.py b/build/automation/bin/update-ip-address.py index 54b2e2448..0e50ba219 100644 --- a/build/automation/bin/update-ip-address.py +++ b/build/automation/bin/update-ip-address.py @@ -8,7 +8,8 @@ def get_ip_address() -> str: # For documentation on ipify see here - https://www.ipify.org/ - return urlopen("https://api.ipify.org").read().decode("utf8") + ip_v4_address = urlopen("https://api.ipify.org").read().decode("utf8") + return f"{ip_v4_address}/32" def get_username(): diff --git a/build/automation/etc/git-secrets/nhsd-rules-banned.regexp b/build/automation/etc/git-secrets/nhsd-rules-banned.regexp index 49b6f695b..94096df98 100644 --- a/build/automation/etc/git-secrets/nhsd-rules-banned.regexp +++ b/build/automation/etc/git-secrets/nhsd-rules-banned.regexp @@ -3,6 +3,8 @@ [a-z]{2}-[a-z-]*-[1,2,3]\.rds\.amazonaws\.com rds\.[a-z]{2}-[a-z-]*-[1,2,3]\.amazonaws\.com dynamodb\.[a-z]{2}-[a-z-]*-[1,2,3]\.amazonaws\.com +arn:aws:.*:.*:[0-9]{12} +[0-9]{12}\.dkr\.ecr\.[a-z-]*-[1,2,3]\.amazonaws\.com [a-z]{2}-[a-z-]*-[1,2,3]\.es\.amazonaws\.com [a-z]*[1-3]\.cache\.amazonaws\.com hooks\.slack\.com/services/T[a-zA-Z0-9]*/B[a-zA-Z0-9]*/[a-zA-Z0-9]* diff --git a/build/automation/etc/githooks/pre-commit b/build/automation/etc/githooks/pre-commit index 9c46104c3..2d2fa297e 100755 --- a/build/automation/etc/githooks/pre-commit +++ b/build/automation/etc/githooks/pre-commit @@ -1,7 +1,7 @@ #!/bin/bash set -e -export PATH=/usr/local/opt/coreutils/libexec/gnubin:/usr/local/opt/findutils/libexec/gnubin:/usr/local/opt/gnu-sed/libexec/gnubin:/usr/local/opt/gnu-tar/libexec/gnubin:/usr/local/opt/grep/libexec/gnubin:/usr/local/opt/make/libexec/gnubin:build/automation/bin:$PATH +export PATH=/opt/homebrew/opt/coreutils/libexec/gnubin:/opt/homebrew/opt/findutils/libexec/gnubin:/opt/homebrew/opt/grep/libexec/gnubin:/opt/homebrew/opt/gnu-sed/libexec/gnubin:/opt/homebrew/opt/gnu-tar/libexec/gnubin:/opt/homebrew/opt/make/libexec/gnubin:/opt/homebrew/bin:/usr/local/opt/coreutils/libexec/gnubin:/usr/local/opt/findutils/libexec/gnubin:/usr/local/opt/grep/libexec/gnubin:/usr/local/opt/gnu-sed/libexec/gnubin:/usr/local/opt/gnu-tar/libexec/gnubin:/usr/local/opt/make/libexec/gnubin:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin:build/automation/bin:$PATH current_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" > /dev/null 2>&1 && pwd)" project_dir=$(git rev-parse --show-toplevel) diff --git a/build/automation/etc/githooks/scripts/python-code-pre-commit.sh b/build/automation/etc/githooks/scripts/python-code-pre-commit.sh index aa1ac20b0..9a40a4936 100755 --- a/build/automation/etc/githooks/scripts/python-code-pre-commit.sh +++ b/build/automation/etc/githooks/scripts/python-code-pre-commit.sh @@ -3,14 +3,6 @@ set -e [ $(make project-check-if-tech-is-included-in-stack NAME=python) == false ] && exit 0 -if [ $(make git-check-if-commit-changed-directory DIR=application PRECOMMIT=true) == true ]; then - make -s python-code-format python-code-check \ - FILES=application -fi - -if [ $(make git-check-if-commit-changed-directory DIR=test PRECOMMIT=true) == true ]; then - make -s python-code-format python-code-check \ - FILES=test -fi +make python-run-ruff-checks exit 0 diff --git a/build/automation/etc/githooks/scripts/terraform-format-pre-commit.sh b/build/automation/etc/githooks/scripts/terraform-format-pre-commit.sh index f47ba983c..a2432a778 100755 --- a/build/automation/etc/githooks/scripts/terraform-format-pre-commit.sh +++ b/build/automation/etc/githooks/scripts/terraform-format-pre-commit.sh @@ -22,7 +22,7 @@ if [ $(make git-check-if-commit-changed-directory DIR=infrastructure PRECOMMIT=t fi # TODO: Add `make docker-run-terraform-tfsec` -# TODO: Add `make docker-run-terraform-checkov` +# TODO: Add `make docker-run-checkov` # TODO: Add `make docker-run-terraform-compliance` # TODO: Add `make docker-run-config-lint` diff --git a/build/automation/init.mk b/build/automation/init.mk index 08540536e..b40d4e8e9 100644 --- a/build/automation/init.mk +++ b/build/automation/init.mk @@ -88,6 +88,10 @@ devops-copy: ### Copy the DevOps automation toolchain scripts from this codebase $(DIR)/build \ $(DIR)/documentation/adr \ $(DIR)/documentation/diagrams + # Clean up + rm -rf \ + $(DIR)/documentation/CONTRIBUTING.md \ + $(DIR)/documentation/ONBOARDING.md # Library files rsync -rav \ --include=build/ \ @@ -108,6 +112,8 @@ devops-copy: ### Copy the DevOps automation toolchain scripts from this codebase cp -fv build/automation/lib/project/template/.github/CODEOWNERS $(DIR)/.github cp -fv build/automation/lib/project/template/.gitattributes $(DIR) ) + mkdir -p $(DIR)/.vscode + cp -fv build/automation/lib/project/template/.vscode/extensions.json $(DIR)/.vscode cp -fv build/automation/tmp/.gitignore $(DIR)/build/automation/tmp/.gitignore cp -fv LICENSE.md $(DIR)/build/automation/LICENSE.md [ -f $(DIR)/docker/docker-compose.yml ] && rm -fv $(DIR)/docker/.gitkeep @@ -131,13 +137,13 @@ devops-copy: ### Copy the DevOps automation toolchain scripts from this codebase ) # Project documentation [ ! -f $(DIR)/README.md ] && cp -fv build/automation/lib/project/template/README.md $(DIR) - [ -f $(DIR)/TODO.md ] && mv -fv $(DIR)/TODO.md $(DIR)/documentation; [ ! -f $(DIR)/documentation/TODO.md ] && cp -fv build/automation/lib/project/template/documentation/TODO.md $(DIR)/documentation + [ -f $(DIR)/Todo.md ] && mv -fv $(DIR)/Todo.md $(DIR)/documentation; [ ! -f $(DIR)/documentation/Todo.md ] && cp -fv build/automation/lib/project/template/documentation/Todo.md $(DIR)/documentation cp -fv build/automation/lib/project/template/documentation/adr/README.md $(DIR)/documentation/adr [ ! -f $(DIR)/documentation/diagrams/C4model.drawio ] && cp -fv build/automation/lib/project/template/documentation/diagrams/C4model* $(DIR)/documentation/diagrams [ ! -f $(DIR)/documentation/diagrams/Infrastructure.drawio ] && cp -fv build/automation/lib/project/template/documentation/diagrams/Infrastructure* $(DIR)/documentation/diagrams [ ! -f $(DIR)/documentation/diagrams/DevOps.drawio ] && cp -fv build/automation/lib/project/template/documentation/diagrams/DevOps* $(DIR)/documentation/diagrams - [ ! -f $(DIR)/documentation/CONTRIBUTING.md ] && cp -fv build/automation/lib/project/template/documentation/CONTRIBUTING.md $(DIR)/documentation - [ ! -f $(DIR)/documentation/ONBOARDING.md ] && cp -fv build/automation/lib/project/template/documentation/ONBOARDING.md $(DIR)/documentation + [ ! -f $(DIR)/documentation/Contributing.md ] && cp -fv build/automation/lib/project/template/documentation/Contributing.md $(DIR)/documentation + [ ! -f $(DIR)/documentation/Onboarding.md ] && cp -fv build/automation/lib/project/template/documentation/Onboarding.md $(DIR)/documentation # --- make _devops-project-clean DIR=$(DIR) # --- @@ -205,6 +211,10 @@ devops-update devops-synchronise: ### Update/upgrade the DevOps automation toolc $(PARENT_PROJECT_DIR)/build \ $(PARENT_PROJECT_DIR)/documentation/adr \ $(PARENT_PROJECT_DIR)/documentation/diagrams + # Clean up + rm -rf \ + $(PARENT_PROJECT_DIR)/documentation/CONTRIBUTING.md \ + $(PARENT_PROJECT_DIR)/documentation/ONBOARDING.md # Library files rsync -rav \ --include=build/ \ @@ -225,6 +235,8 @@ devops-update devops-synchronise: ### Update/upgrade the DevOps automation toolc cp -fv build/automation/lib/project/template/.github/CODEOWNERS $(PARENT_PROJECT_DIR)/.github cp -fv build/automation/lib/project/template/.gitattributes $(PARENT_PROJECT_DIR) ) + mkdir -p $(PARENT_PROJECT_DIR)/.vscode + cp -fv build/automation/lib/project/template/.vscode/extensions.json $(PARENT_PROJECT_DIR)/.vscode/extensions.json cp -fv build/automation/tmp/.gitignore $(PARENT_PROJECT_DIR)/build/automation/tmp/.gitignore cp -fv LICENSE.md $(PARENT_PROJECT_DIR)/build/automation/LICENSE.md [ -f $(PARENT_PROJECT_DIR)/docker/docker-compose.yml ] && rm -fv $(PARENT_PROJECT_DIR)/docker/.gitkeep @@ -248,13 +260,13 @@ devops-update devops-synchronise: ### Update/upgrade the DevOps automation toolc ) # Project documentation [ ! -f $(PARENT_PROJECT_DIR)/README.md ] && cp -fv build/automation/lib/project/template/README.md $(PARENT_PROJECT_DIR) - [ -f $(PARENT_PROJECT_DIR)/TODO.md ] && mv -fv $(PARENT_PROJECT_DIR)/TODO.md $(PARENT_PROJECT_DIR)/documentation; [ ! -f $(PARENT_PROJECT_DIR)/documentation/TODO.md ] && cp -fv build/automation/lib/project/template/documentation/TODO.md $(PARENT_PROJECT_DIR)/documentation + [ -f $(PARENT_PROJECT_DIR)/Todo.md ] && mv -fv $(PARENT_PROJECT_DIR)/Todo.md $(PARENT_PROJECT_DIR)/documentation; [ ! -f $(PARENT_PROJECT_DIR)/documentation/Todo.md ] && cp -fv build/automation/lib/project/template/documentation/Todo.md $(PARENT_PROJECT_DIR)/documentation cp -fv build/automation/lib/project/template/documentation/adr/README.md $(PARENT_PROJECT_DIR)/documentation/adr [ ! -f $(PARENT_PROJECT_DIR)/documentation/diagrams/C4model.drawio ] && cp -fv build/automation/lib/project/template/documentation/diagrams/C4model* $(PARENT_PROJECT_DIR)/documentation/diagrams [ ! -f $(PARENT_PROJECT_DIR)/documentation/diagrams/Infrastructure.drawio ] && cp -fv build/automation/lib/project/template/documentation/diagrams/Infrastructure* $(PARENT_PROJECT_DIR)/documentation/diagrams [ ! -f $(PARENT_PROJECT_DIR)/documentation/diagrams/DevOps.drawio ] && cp -fv build/automation/lib/project/template/documentation/diagrams/DevOps* $(PARENT_PROJECT_DIR)/documentation/diagrams - [ ! -f $(PARENT_PROJECT_DIR)/documentation/CONTRIBUTING.md ] && cp -fv build/automation/lib/project/template/documentation/CONTRIBUTING.md $(PARENT_PROJECT_DIR)/documentation - [ ! -f $(PARENT_PROJECT_DIR)/documentation/ONBOARDING.md ] && cp -fv build/automation/lib/project/template/documentation/ONBOARDING.md $(PARENT_PROJECT_DIR)/documentation + [ ! -f $(PARENT_PROJECT_DIR)/documentation/Contributing.md ] && cp -fv build/automation/lib/project/template/documentation/Contributing.md $(PARENT_PROJECT_DIR)/documentation + [ ! -f $(PARENT_PROJECT_DIR)/documentation/Onboarding.md ] && cp -fv build/automation/lib/project/template/documentation/Onboarding.md $(PARENT_PROJECT_DIR)/documentation # --- make _devops-project-clean DIR=$(PARENT_PROJECT_DIR) # --- @@ -284,7 +296,9 @@ devops-update devops-synchronise: ### Update/upgrade the DevOps automation toolc if [ 0 -lt $$(git status -s | wc -l) ]; then git add . if [[ "$(PERFORM_COMMIT)" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$$ ]]; then - git commit -S -m "Update automation scripts to $$version" || echo "Please, check and commit the changes with the following message: \"Update automation scripts to $$version\"" + git commit -S -m "Update automation scripts to $$version" + else + echo "Please, check and commit the changes with the following message: \"Update automation scripts to $$version\"" fi fi } @@ -573,29 +587,29 @@ VAR_DIR := $(abspath $(DEVOPS_PROJECT_DIR)/var) VAR_DIR_REL := $(shell echo $(VAR_DIR) | sed "s;$(PROJECT_DIR);;g") APPLICATION_DIR := $(abspath $(or $(APPLICATION_DIR), $(PROJECT_DIR)/application)) -APPLICATION_DIR_REL = $(shell echo $(APPLICATION_DIR) | sed "s;$(PROJECT_DIR);;g") +APPLICATION_DIR_REL := $(shell echo $(APPLICATION_DIR) | sed "s;$(PROJECT_DIR);;g") APPLICATION_TEST_DIR := $(abspath $(or $(APPLICATION_TEST_DIR), $(PROJECT_DIR)/test)) APPLICATION_TEST_DIR_REL = $(shell echo $(APPLICATION_TEST_DIR) | sed "s;$(PROJECT_DIR);;g") CONFIG_DIR := $(abspath $(or $(CONFIG_DIR), $(PROJECT_DIR)/config)) -CONFIG_DIR_REL = $(shell echo $(CONFIG_DIR) | sed "s;$(PROJECT_DIR);;g") +CONFIG_DIR_REL := $(shell echo $(CONFIG_DIR) | sed "s;$(PROJECT_DIR);;g") DATA_DIR := $(abspath $(or $(DATA_DIR), $(PROJECT_DIR)/data)) -DATA_DIR_REL = $(shell echo $(DATA_DIR) | sed "s;$(PROJECT_DIR);;g") +DATA_DIR_REL := $(shell echo $(DATA_DIR) | sed "s;$(PROJECT_DIR);;g") DEPLOYMENT_DIR := $(abspath $(or $(DEPLOYMENT_DIR), $(PROJECT_DIR)/deployment)) DEPLOYMENT_DIR_REL = $(shell echo $(DEPLOYMENT_DIR) | sed "s;$(PROJECT_DIR);;g") GITHOOKS_DIR := $(abspath $(ETC_DIR)/githooks) -GITHOOKS_DIR_REL = $(shell echo $(GITHOOKS_DIR) | sed "s;$(PROJECT_DIR);;g") +GITHOOKS_DIR_REL := $(shell echo $(GITHOOKS_DIR) | sed "s;$(PROJECT_DIR);;g") DOCUMENTATION_DIR := $(abspath $(or $(DOCUMENTATION_DIR), $(PROJECT_DIR)/documentation)) -DOCUMENTATION_DIR_REL = $(shell echo $(DOCUMENTATION_DIR) | sed "s;$(PROJECT_DIR);;g") +DOCUMENTATION_DIR_REL := $(shell echo $(DOCUMENTATION_DIR) | sed "s;$(PROJECT_DIR);;g") INFRASTRUCTURE_DIR := $(abspath $(or $(INFRASTRUCTURE_DIR), $(PROJECT_DIR)/infrastructure)) -INFRASTRUCTURE_DIR_REL = $(shell echo $(INFRASTRUCTURE_DIR) | sed "s;$(PROJECT_DIR);;g") +INFRASTRUCTURE_DIR_REL := $(shell echo $(INFRASTRUCTURE_DIR) | sed "s;$(PROJECT_DIR);;g") JQ_DIR_REL := $(shell echo $(abspath $(LIB_DIR)/jq) | sed "s;$(PROJECT_DIR);;g") -GIT_BRANCH_PATTERN_MAIN := ^(master|develop)$$ -GIT_BRANCH_PATTERN_PREFIX := ^(task|spike|automation|test|bugfix|hotfix|fix|release|migration) +GIT_BRANCH_PATTERN_MAIN := ^(main|master|develop)$$ +GIT_BRANCH_PATTERN_PREFIX := ^(task|spike|automation|test|bugfix|hotfix|fix|release|migration|refactor|documentation) GIT_BRANCH_PATTERN_SUFFIX := ([A-Z]{2,5}-([0-9]{1,5}|X{1,5})_[A-Z][a-z]+_[A-Za-z0-9]+_[A-Za-z0-9_]+)$$ -GIT_BRANCH_PATTERN_ADDITIONAL := ^(task/Update_(automation_scripts|dependencies|documentation|tests|versions)|task/Refactor|devops/[A-Z][a-z]+_[A-Za-z0-9_]+_[A-Za-z0-9_]+|alignment/[A-Z][a-z]+_[A-Za-z0-9_]+_[A-Za-z0-9_]+)$$ +GIT_BRANCH_PATTERN_ADDITIONAL := ^(task/Update_(automation_scripts|dependencies|documentation|tests|versions)|task/Refactor|task/Refactor_[A-Za-z0-9_]+_[A-Za-z0-9_]+|refactor/[A-Z][a-z]+_[A-Za-z0-9_]+_[A-Za-z0-9_]+|devops/[A-Z][a-z]+_[A-Za-z0-9_]+_[A-Za-z0-9_]+|alignment/[A-Z][a-z]+_[A-Za-z0-9_]+_[A-Za-z0-9_]+|dependabot/.*)$$ GIT_BRANCH_PATTERN := $(GIT_BRANCH_PATTERN_MAIN)|$(GIT_BRANCH_PATTERN_PREFIX)/$(GIT_BRANCH_PATTERN_SUFFIX)|$(GIT_BRANCH_PATTERN_ADDITIONAL) -GIT_BRANCH_MAX_LENGTH := 72 +GIT_BRANCH_MAX_LENGTH := 100 GIT_TAG_PATTERN := [0-9]{12,14}-[a-z]{3,10} GIT_COMMIT_MESSAGE_PATTERN_MAIN := ^(([A-Z]{2,5}-([0-9]{1,5}|X{1,5}) [A-Z][a-z]+ [[:print:]]+ [[:print:]]+[^!?,.:;=-]|Update (automation scripts|dependencies|documentation|tests|versions))([[:print:]][^!?,.:;=-])*)$$|^((Update|Refactor|Automate|Test|Fix|Release|Migrate) [[:print:]]+ [[:print:]]+[^!?,.:;=-])$$ GIT_COMMIT_MESSAGE_PATTERN_ADDITIONAL := ^([A-Z]{2,5}-([0-9]{1,5}|X{1,5}) [A-Z][a-z]+ [[:print:]]+ [[:print:]]+[^!?,.:;=-]|[A-Z][a-z]+ [[:print:]]+ [[:print:]]+[^!?,.:;=-])$$|([A-Z][[:print:]]+ \[ci:[[:blank:]]?[,a-z0-9-]+\]) @@ -612,6 +626,7 @@ BUILD_COMMIT_DATE := $(or $(shell TZ=UTC git show -s --format=%cd --date=format- BUILD_COMMIT_AUTHOR_NAME := $(shell git show -s --format='%an' HEAD 2> /dev/null ||:) BUILD_COMMIT_AUTHOR_EMAIL := $(shell git show -s --format='%ae' HEAD 2> /dev/null ||:) BUILD_COMMIT_MESSAGE := $(shell git log -1 --pretty=%B HEAD 2> /dev/null ||:) +BUILD_COMMIT_DATETIME := $(or $(shell TZ=UTC git show -s --format=%cd --date=format-local:%Y%m%d%H%M%S HEAD 2> /dev/null ||:), unknown) BUILD_TAG := $(shell echo "$(BUILD_TAG)" | grep -Eq ^jenkins- && echo $(BUILD_TIMESTAMP)-$(BUILD_COMMIT_HASH) || echo $(or $(BUILD_TAG), $(BUILD_TIMESTAMP)-$(BUILD_COMMIT_HASH))) USER_ID := $(shell id -u) @@ -622,12 +637,15 @@ GOSS_PATH := $(BIN_DIR)/goss-linux-amd64 SETUP_COMPLETE_FLAG_FILE := $(TMP_DIR)/.make-devops-setup-complete PROFILE := $(or $(PROFILE), local) -ENVIRONMENT := $(or $(ENVIRONMENT), $(or $(shell ([ $(PROFILE) = local ] && echo local) || (echo $(BUILD_BRANCH) | grep -Eoq '$(GIT_BRANCH_PATTERN_SUFFIX)' && (echo $(BUILD_BRANCH) | grep -Eo '[A-Za-z]{2,5}-[0-9]{1,5}' | tr '[:upper:]' '[:lower:]') || (echo $(BUILD_BRANCH) | grep -Eoq '^tags/$(GIT_TAG_PATTERN)' && echo $(PROFILE)) || ([ $(BUILD_BRANCH) = master ] && echo $(PROFILE)))), unknown)) +ENVIRONMENT := $(or $(ENVIRONMENT), $(or $(shell ([ $(PROFILE) = local ] && echo local) || (echo $(BUILD_BRANCH) | grep -Eoq '$(GIT_BRANCH_PATTERN_SUFFIX)' && (echo $(BUILD_BRANCH) | grep -Eo '[A-Za-z]{2,5}-[0-9]{1,5}' | tr '[:upper:]' '[:lower:]') || (echo $(BUILD_BRANCH) | grep -Eoq '^tags/$(GIT_TAG_PATTERN)' && echo $(PROFILE)) || (([ $(BUILD_BRANCH) = main ] || [ $(BUILD_BRANCH) = master ]) && echo $(PROFILE)))), unknown)) PATH_HOMEBREW := /opt/homebrew/opt/coreutils/libexec/gnubin:/opt/homebrew/opt/findutils/libexec/gnubin:/opt/homebrew/opt/grep/libexec/gnubin:/opt/homebrew/opt/gnu-sed/libexec/gnubin:/opt/homebrew/opt/gnu-tar/libexec/gnubin:/opt/homebrew/opt/make/libexec/gnubin:/opt/homebrew/bin:/usr/local/opt/coreutils/libexec/gnubin:/usr/local/opt/findutils/libexec/gnubin:/usr/local/opt/grep/libexec/gnubin:/usr/local/opt/gnu-sed/libexec/gnubin:/usr/local/opt/gnu-tar/libexec/gnubin:/usr/local/opt/make/libexec/gnubin PATH_DEVOPS := $(BIN_DIR):$(HOME)/.pyenv/bin:$(HOME)/.pyenv/shims PATH_SYSTEM := /usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin +BASH_VERSION := $(shell bash --version | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +MAKE_VERSION := $(shell make --version | grep -Eo '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1) + # ============================================================================== # `make` configuration @@ -747,6 +765,7 @@ endif ifeq (true, $(shell [ ! -f $(SETUP_COMPLETE_FLAG_FILE) ] && echo true)) ifeq (true, $(shell [ $(SYSTEM_DIST) = "macos" ] && echo true)) +ifneq ("dumb", "$$TERM") # macOS: Xcode Command Line Tools ifneq (0, $(shell xcode-select -p > /dev/null 2>&1; echo $$?)) $(info ) @@ -790,9 +809,6 @@ endif ifneq (0, $(shell which docker > /dev/null 2>&1; echo $$?)) $(error $(shell tput setaf 202; echo "WARNING: Please, before proceeding install Docker"; tput sgr0)) endif -# *NIX: Docker Compose -ifneq (0, $(shell which docker-compose > /dev/null 2>&1; echo $$?)) -$(error $(shell tput setaf 202; echo "WARNING: Please, before proceeding install Docker Compose"; tput sgr0)) endif endif endif diff --git a/build/automation/lib/aws.mk b/build/automation/lib/aws.mk index f848df79f..e21942c79 100644 --- a/build/automation/lib/aws.mk +++ b/build/automation/lib/aws.mk @@ -315,6 +315,7 @@ aws-ecr-create-repository: ### Create ECR repository to store an image - mandato make -s docker-run-tools ARGS="$$(echo $(AWSCLI) | grep awslocal > /dev/null 2>&1 && echo '--env LOCALSTACK_HOST=$(LOCALSTACK_HOST)' ||:)" CMD=" \ $(AWSCLI) ecr create-repository \ --repository-name $(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT)/$(NAME) \ + --image-scanning-configuration scanOnPush=true \ --tags Key=Programme,Value=$(PROGRAMME) Key=Service,Value=$(SERVICE_TAG) \ " make -s docker-run-tools ARGS="$$(echo $(AWSCLI) | grep awslocal > /dev/null 2>&1 && echo '--env LOCALSTACK_HOST=$(LOCALSTACK_HOST)' ||:)" CMD=" \ @@ -331,6 +332,54 @@ aws-ecr-get-image-digest: ### Get ECR image digest by matching tag pattern - man --repository-name $(shell echo $(REPO) | sed "s;$(AWS_ECR)/;;g") \ " | make -s docker-run-tools CMD="jq -rf $$file" | head -n 1 +aws-ecr-get-security-scan: ### Fetches container scan report and returns findings - Mandatory REPOSITORY, TAG=[image tag], UNACCEPTABLE_VULNERABILITY_LEVELS=[LOW,MEDIUM,HIGH,CRITICAL]; optional: FAIL_ON_WARNINGS=false, SHOW_ALL_WARNINGS=false + make -s aws-ecr-wait-for-image-scan-complete REPOSITORY=$(REPOSITORY) TAG=$(TAG) + SCAN_FINDINGS=$$(make -s aws-ecr-describe-image-scan-findings REPOSITORY=$(REPOSITORY) TAG=$(TAG)) + SCAN_WARNINGS=$$(echo $$SCAN_FINDINGS | jq '.imageScanFindings.findingSeverityCounts') + CRITICAL=$$(echo $$SCAN_WARNINGS | jq '.CRITICAL') + HIGH=$$(echo $$SCAN_WARNINGS | jq '.HIGH') + MEDIUM=$$(echo $$SCAN_WARNINGS | jq '.MEDIUM') + LOW=$$(echo $$SCAN_WARNINGS | jq '.LOW') + INFORMATIONAL=$$(echo $$SCAN_WARNINGS | jq '.INFORMATIONAL') + UNDEFINED=$$(echo $$SCAN_WARNINGS | jq '.UNDEFINED') + if [[ "$(SHOW_ALL_WARNINGS)" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$$ ]]; then + echo "CRITICAL: $$CRITICAL" + echo "HIGH: $$HIGH" + echo "MEDIUM: $$MEDIUM" + echo "LOW: $$LOW" + echo "INFORMATIONAL: $$INFORMATIONAL" + echo "UNDEFINED: $$UNDEFINED" + fi + IS_UNACCEPTABLE_WARNINGS=false + for LEVEL in $$(echo $(UNACCEPTABLE_VULNERABILITY_LEVELS) | tr "," "\n" | tr [:lower:] [:upper:]); do + if [ "$$(echo $$(eval echo \"\$$$$LEVEL\"))" != null ]; then + echo $$LEVEL is above the threshold + IS_UNACCEPTABLE_WARNINGS=true + fi + done + echo "For more details visit https://$(AWS_REGION).console.aws.amazon.com/ecr/repositories/private/$(AWS_ACCOUNT_ID_MGMT)/$(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT)/$(REPOSITORY)?region=$(AWS_REGION)" + if [[ "$(FAIL_ON_WARNINGS)" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$$ ]] && [ $$IS_UNACCEPTABLE_WARNINGS == "true" ]; then + exit 1 + fi + +aws-ecr-wait-for-image-scan-complete: ### Waits for ECR image scan report - REPOSITORY, TAG=[image tag] + make -s docker-run-tools ARGS="$$(echo $(AWSCLI) | grep awslocal > /dev/null 2>&1 && echo '--env LOCALSTACK_HOST=$(LOCALSTACK_HOST)' ||:)" CMD=" \ + $(AWSCLI) ecr wait image-scan-complete \ + --registry-id $(AWS_ACCOUNT_ID_MGMT) \ + --repository-name $(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT)/$(REPOSITORY) \ + --image-id imageTag=$(TAG) \ + --output json \ + " + +aws-ecr-describe-image-scan-findings: ### Describes ECR image scan report - REPOSITORY, TAG=[image tag] + make -s docker-run-tools ARGS="$$(echo $(AWSCLI) | grep awslocal > /dev/null 2>&1 && echo '--env LOCALSTACK_HOST=$(LOCALSTACK_HOST)' ||:)" CMD=" \ + $(AWSCLI) ecr describe-image-scan-findings \ + --registry-id $(AWS_ACCOUNT_ID_MGMT) \ + --repository-name $(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT)/$(REPOSITORY) \ + --image-id imageTag=$(TAG) \ + --output json \ + " + aws-ses-verify-email-identity: ### Verify SES email address - mandatory: NAME make -s docker-run-tools ARGS="$$(echo $(AWSCLI) | grep awslocal > /dev/null 2>&1 && echo '--env LOCALSTACK_HOST=$(LOCALSTACK_HOST)' ||:)" CMD=" \ $(AWSCLI) ses verify-email-identity \ @@ -520,6 +569,9 @@ aws-accounts-create-template-config-file-v1: ### Create AWS accounts variables t aws-dynamodb-query \ aws-ecr-get-image-digest \ aws-ecr-get-login-password \ + aws-ecr-get-security-scan \ + aws-ecr-wait-for-image-scan-complete \ + aws-ecr-describe-image-scan-findings \ aws-elasticsearch-get-endpoint \ aws-iam-policy-exists \ aws-iam-role-exists \ diff --git a/build/automation/lib/aws/aws-ecr-create-repository-policy.json b/build/automation/lib/aws/aws-ecr-create-repository-policy.json index 302750bf4..21d517f46 100644 --- a/build/automation/lib/aws/aws-ecr-create-repository-policy.json +++ b/build/automation/lib/aws/aws-ecr-create-repository-policy.json @@ -22,9 +22,9 @@ "Effect": "Allow", "Principal": { "AWS": [ - "arn:aws:iam::AWS_ACCOUNT_ID_MGMT_TO_REPLACE:role/UECPUServiceDeveloper", - "arn:aws:iam::AWS_ACCOUNT_ID_NONPROD_TO_REPLACE:role/UECPUServiceDeveloper", - "arn:aws:iam::AWS_ACCOUNT_ID_PROD_TO_REPLACE:role/UECPUSupportServiceDeveloper", + "arn:aws:iam::AWS_ACCOUNT_ID_MGMT_TO_REPLACE:role/UECDoSINTServiceDeveloper", + "arn:aws:iam::AWS_ACCOUNT_ID_NONPROD_TO_REPLACE:role/UECDoSINTServiceDeveloper", + "arn:aws:iam::AWS_ACCOUNT_ID_PROD_TO_REPLACE:role/UECDoSINTSupportServiceDeveloper", "arn:aws:iam::AWS_ACCOUNT_ID_NONPROD_TO_REPLACE:role/Developer", "arn:aws:iam::AWS_ACCOUNT_ID_PROD_TO_REPLACE:role/Developer" ] @@ -46,11 +46,14 @@ "Effect": "Deny", "NotPrincipal": { "AWS": [ - "arn:aws:iam::AWS_ACCOUNT_ID_MGMT_TO_REPLACE:role/UECPUPipelineRole", + "arn:aws:iam::AWS_ACCOUNT_ID_MGMT_TO_REPLACE:role/UECDoSINTPipelineRole", "arn:aws:iam::AWS_ACCOUNT_ID_MGMT_TO_REPLACE:root" ] }, - "Action": ["ecr:BatchDeleteImage", "ecr:DeleteRepository"] + "Action": [ + "ecr:BatchDeleteImage", + "ecr:DeleteRepository" + ] }, { "Sid": "LambdaECRImageRetrievalPolicy", diff --git a/build/automation/lib/docker.mk b/build/automation/lib/docker.mk index 7302dbb2b..07a199ba3 100644 --- a/build/automation/lib/docker.mk +++ b/build/automation/lib/docker.mk @@ -5,29 +5,30 @@ DOCKER_LIB_DIR = $(LIB_DIR)/docker DOCKER_LIB_DIR_REL = $(shell echo $(DOCKER_LIB_DIR) | sed "s;$(PROJECT_DIR);;g") DOCKER_LIB_IMAGE_DIR = $(LIB_DIR)/docker/image DOCKER_LIB_IMAGE_DIR_REL = $(shell echo $(DOCKER_LIB_IMAGE_DIR) | sed "s;$(PROJECT_DIR);;g") -DOCKER_NETWORK = $(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT)/$(BUILD_ID) +# DOCKER_NETWORK = $(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT)/$(BUILD_ID) +DOCKER_NETWORK = $(PROJECT_GROUP_SHORT)_$(PROJECT_NAME_SHORT)_$(BUILD_ID) DOCKER_REGISTRY = $(AWS_ECR)/$(PROJECT_GROUP_SHORT)/$(PROJECT_NAME_SHORT) DOCKER_LIBRARY_REGISTRY = nhsd -DOCKER_ALPINE_VERSION = 3.14.2 -DOCKER_COMPOSER_VERSION = 2.0.13 +DOCKER_ALPINE_VERSION = 3.16.0 +DOCKER_COMPOSER_VERSION = 2.3.7 DOCKER_CONFIG_LINT_VERSION = v1.6.0 -DOCKER_DIND_VERSION = 20.10.8-dind -DOCKER_EDITORCONFIG_CHECKER_VERSION = 2.3.5 -DOCKER_ELASTICSEARCH_VERSION = 7.13.0 -DOCKER_GRADLE_VERSION = 7.0.2-jdk$(JAVA_VERSION) +DOCKER_DIND_VERSION = 20.10.17-dind +DOCKER_EDITORCONFIG_CHECKER_VERSION = 2.4.0 +DOCKER_ELASTICSEARCH_VERSION = 8.2.2 +DOCKER_GRADLE_VERSION = 7.2.0-jdk$(JAVA_VERSION) DOCKER_LOCALSTACK_VERSION = $(LOCALSTACK_VERSION) -DOCKER_MAVEN_VERSION = 3.8.1-openjdk-$(JAVA_VERSION)-slim -DOCKER_NGINX_VERSION = 1.21.0-alpine +DOCKER_MAVEN_VERSION = 3.8.3-openjdk-$(JAVA_VERSION)-slim +DOCKER_NGINX_VERSION = 1.22.0-alpine DOCKER_NODE_VERSION = $(NODE_VERSION)-alpine DOCKER_OPENJDK_VERSION = $(JAVA_VERSION)-alpine DOCKER_POSTGRES_VERSION = $(POSTGRES_VERSION)-alpine DOCKER_POSTMAN_NEWMAN_VERSION = $(POSTMAN_NEWMAN_VERSION)-alpine DOCKER_PYTHON_VERSION = $(PYTHON_VERSION)-alpine DOCKER_SONAR_SCANNER_CLI_VERSION = $(SONAR_SCANNER_CLI_VERSION) -DOCKER_TERRAFORM_CHECKOV_VERSION = 2.0.170 -DOCKER_TERRAFORM_COMPLIANCE_VERSION = 1.3.14 -DOCKER_TERRAFORM_TFSEC_VERSION = v0.39.42 +DOCKER_CHECKOV_VERSION = 2.5.6 +DOCKER_TERRAFORM_COMPLIANCE_VERSION = 1.3.33 +DOCKER_TERRAFORM_TFSEC_VERSION = v1.28.4-amd64 DOCKER_TERRAFORM_VERSION = $(TERRAFORM_VERSION) DOCKER_WIREMOCK_VERSION = $(WIREMOCK_VERSION)-alpine @@ -43,6 +44,10 @@ DOCKER_LIBRARY_TOOLS_VERSION = $(shell cat $(DOCKER_LIB_IMAGE_DIR)/tools/VERSION COMPOSE_HTTP_TIMEOUT := $(or $(COMPOSE_HTTP_TIMEOUT), 6000) DOCKER_CLIENT_TIMEOUT := $(or $(DOCKER_CLIENT_TIMEOUT), 6000) +DOCKER_CMD := $(or $(DOCKER_CMD), $(shell command -v docker >/dev/null 2>&1 && echo docker || echo podman)) +IS_PODMAN := $(shell [[ "$(DOCKER_CMD)" == *podman ]] && echo true || echo false) +OUTPUT_OPTION := $(if $(filter false,$(IS_PODMAN)),--output type=docker) + # ============================================================================== docker-create-from-template: ### Create Docker image from template - mandatory: NAME,TEMPLATE=[library template image name] @@ -65,7 +70,7 @@ docker-create-from-template: ### Create Docker image from template - mandatory: # ============================================================================== docker-config: ### Configure Docker networking - docker network create $(DOCKER_NETWORK) 2> /dev/null ||: + $(DOCKER_CMD) network create $(DOCKER_NETWORK) 2> /dev/null ||: docker-build docker-image: ### Build Docker image - mandatory: NAME; optional: VERSION,FROM_CACHE=true,BUILD_OPTS=[build options],EXAMPLE=true reg=$$(make _docker-get-reg) @@ -80,6 +85,9 @@ docker-build docker-image: ### Build Docker image - mandatory: NAME; optional: V elif [ -d $(DOCKER_DIR)/$(NAME) ] && [ -z "$(__DOCKER_BUILD)" ]; then cd $(DOCKER_DIR)/$(NAME) make build __DOCKER_BUILD=true && exit || cd $(PROJECT_DIR) + elif [ -d $(DOCKER_DIR)/$(GENERIC_IMAGE_NAME) ] && [ -z "$(__DOCKER_BUILD)" ]; then + cd $(DOCKER_DIR)/$(GENERIC_IMAGE_NAME) + make build __DOCKER_BUILD=true && exit || cd $(PROJECT_DIR) fi # Dockerfile make NAME=$(NAME) \ @@ -96,7 +104,7 @@ docker-build docker-image: ### Build Docker image - mandatory: NAME; optional: V export IMAGE=$$reg/$(NAME)$(shell [ -n "$(EXAMPLE)" ] && echo -example) export VERSION=$$(make docker-image-get-version) make -s file-replace-variables FILE=$$dir/Dockerfile.effective - docker build --rm \ + $(DOCKER_CMD) buildx build --rm \ --build-arg IMAGE=$$IMAGE \ --build-arg VERSION=$$VERSION \ --build-arg BUILD_ID=$(BUILD_ID) \ @@ -105,6 +113,7 @@ docker-build docker-image: ### Build Docker image - mandatory: NAME; optional: V --build-arg BUILD_BRANCH=$(BUILD_BRANCH) \ --build-arg BUILD_COMMIT_HASH=$(BUILD_COMMIT_HASH) \ --build-arg BUILD_COMMIT_DATE=$(BUILD_COMMIT_DATE) \ + $(OUTPUT_OPTION) \ --label name=$$IMAGE \ --label version=$$VERSION \ --label build-id=$(BUILD_ID) \ @@ -118,12 +127,12 @@ docker-build docker-image: ### Build Docker image - mandatory: NAME; optional: V --tag $$reg/$(NAME)$(shell [ -n "$(EXAMPLE)" ] && echo -example):$$(make docker-image-get-version) \ $$dir # Tag - docker tag \ + $(DOCKER_CMD) tag \ $$reg/$(NAME)$(shell [ -n "$(EXAMPLE)" ] && echo -example):$$(make docker-image-get-version) \ $$reg/$(NAME)$(shell [ -n "$(EXAMPLE)" ] && echo -example):latest - docker rmi --force $$(docker images | grep "" | awk '{ print $$3 }') 2> /dev/null ||: + $(DOCKER_CMD) rmi --force $$($(DOCKER_CMD) images | grep "" | awk '{ print $$3 }') 2> /dev/null ||: make docker-image-keep-latest-only NAME=$(NAME) - docker image inspect $$reg/$(NAME)$(shell [ -n "$(EXAMPLE)" ] && echo -example):latest --format='{{.Size}}' + $(DOCKER_CMD) image inspect $$reg/$(NAME)$(shell [ -n "$(EXAMPLE)" ] && echo -example):latest --format='{{.Size}}' docker-test: ### Test image - mandatory: NAME; optional: ARGS,CMD,GOSS_OPTS,EXAMPLE=true dir=$$(make _docker-get-dir) @@ -139,9 +148,9 @@ docker-test: ### Test image - mandatory: NAME; optional: ARGS,CMD,GOSS_OPTS,EXAM docker-login: ### Log into the Docker registry - optional: DOCKER_USERNAME,DOCKER_PASSWORD if [ -n "$(DOCKER_USERNAME)" ] && [ -n "$$(make _docker-get-login-password)" ]; then - make _docker-get-login-password | docker login --username "$(DOCKER_USERNAME)" --password-stdin + make _docker-get-login-password | $(DOCKER_CMD) login --username "$(DOCKER_USERNAME)" --password-stdin else - make aws-ecr-get-login-password | docker login --username AWS --password-stdin $(AWS_ECR) + make aws-ecr-get-login-password | $(DOCKER_CMD) login --username AWS --password-stdin $(AWS_ECR) fi docker-create-repository: ### Create Docker repository to store an image - mandatory: NAME; optional: POLICY_FILE=[policy file] @@ -151,11 +160,11 @@ docker-push: ### Push Docker image - mandatory: NAME; optional: VERSION|TAG make docker-login reg=$$(make _docker-get-reg) if [ -n "$(or $(VERSION), $(TAG))" ]; then - docker push $$reg/$(NAME):$(or $(VERSION), $(TAG)) + $(DOCKER_CMD) push $$reg/$(NAME):$(or $(VERSION), $(TAG)) else - docker push $$reg/$(NAME):$$(make docker-image-get-version) + $(DOCKER_CMD) push $$reg/$(NAME):$$(make docker-image-get-version) fi - docker push $$reg/$(NAME):latest 2> /dev/null ||: + $(DOCKER_CMD) push $$reg/$(NAME):latest 2> /dev/null ||: docker-push-for-production: ### Push Docker image - mandatory: NAME; optional: VERSION|TAG make docker-login @@ -170,23 +179,23 @@ docker-pull: ### Pull Docker image - mandatory: NAME,DIGEST|VERSION|TAG [ $$(make _docker-is-lib-image) == false ] && make docker-login reg=$$(make _docker-get-reg) if [ -n "$(DIGEST)" ]; then - docker pull $$reg/$(NAME)@$(DIGEST) ||: + $(DOCKER_CMD) pull $$reg/$(NAME)@$(DIGEST) ||: else - docker pull $$reg/$(NAME):$(or $(VERSION), $(TAG)) ||: + $(DOCKER_CMD) pull $$reg/$(NAME):$(or $(VERSION), $(TAG)) ||: fi docker-tag: ### Tag latest or provide arguments - mandatory: NAME,VERSION|TAG|[SOURCE,TARGET]|[DIGEST,VERSION|TAG] reg=$$(make _docker-get-reg) if [ -n "$(DIGEST)" ] && [ -n "$(TAG)" ]; then - docker tag \ + $(DOCKER_CMD) tag \ $$reg/$(NAME)@$(DIGEST) \ $$reg/$(NAME):$(or $(VERSION), $(TAG)) elif [ -n "$(SOURCE)" ] && [ -n "$(TARGET)" ]; then - docker tag \ + $(DOCKER_CMD) tag \ $$reg/$(NAME):$(SOURCE) \ $$reg/$(NAME):$(TARGET) elif [ -n "$(or $(VERSION), $(TAG))" ]; then - docker tag \ + $(DOCKER_CMD) tag \ $$reg/$(NAME):latest \ $$reg/$(NAME):$(or $(VERSION), $(TAG)) fi @@ -242,7 +251,7 @@ docker-create-dockerfile: ### Create effective Dockerfile - mandatory: NAME; op s#FROM postgres:latest#FROM postgres:$(DOCKER_POSTGRES_VERSION)#g; \ s#FROM postman/newman:latest#FROM postman/newman:$(DOCKER_POSTMAN_NEWMAN_VERSION)#g; \ s#FROM python:latest#FROM python:$(DOCKER_PYTHON_VERSION)#g; \ - s#FROM rodolpheche/wiremock:latest#FROM rodolpheche/wiremock:$(DOCKER_WIREMOCK_VERSION)#g; \ + s#FROM wiremock/wiremock:latest#FROM wiremock/wiremock:$(DOCKER_WIREMOCK_VERSION)#g; \ " Dockerfile.effective cd $$dir @@ -276,25 +285,25 @@ docker-image-set-version: ### Set effective Docker image version - mandatory: NA docker-image-pull-or-build: ### Pull or build image - mandatory: NAME; optional VERSION|TAG=[defaults to 'latest'],LATEST=true version=$(or $(or $(VERSION), $(TAG)), latest) image=$(DOCKER_LIBRARY_REGISTRY)/$(NAME):$$version - if [ -z "$$(docker images --filter=reference="$$image" --quiet)" ]; then + if [ -z "$$($(DOCKER_CMD) images --filter=reference="$$image" --quiet)" ]; then make docker-pull NAME=$(NAME) VERSION=$$version ||: fi - if [ -z "$$(docker images --filter=reference="$$image" --quiet)" ]; then + if [ -z "$$($(DOCKER_CMD) images --filter=reference="$$image" --quiet)" ]; then make docker-build NAME=$(NAME) || ( \ echo "ERROR: No image $$image found"; \ exit 1 \ ) fi - if [ -n "$$(docker images --filter=reference="$$image" --quiet)" ]; then + if [ -n "$$($(DOCKER_CMD) images --filter=reference="$$image" --quiet)" ]; then [[ "$(LATEST)" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$$ ]] && \ make docker-tag NAME=$(NAME) SOURCE=$$version TARGET=latest ||: fi docker-image-keep-latest-only: ### Remove other images than latest - mandatory: NAME reg=$$(make _docker-get-reg) - docker rmi --force $$( \ - docker images --filter=reference="$$reg/$(NAME):*" --quiet | \ - grep -v $$(docker images --filter=reference="$$reg/$(NAME):latest" --quiet) \ + $(DOCKER_CMD) rmi --force $$( \ + $(DOCKER_CMD) images --filter=reference="$$reg/$(NAME):*" --quiet | \ + grep -v $$($(DOCKER_CMD) images --filter=reference="$$reg/$(NAME):latest" --quiet) \ ) 2> /dev/null ||: docker-image-start: ### Start container - mandatory: NAME; optional: CMD,DIR,ARGS=[Docker args],VARS_FILE=[Makefile vars file],EXAMPLE=true @@ -360,7 +369,7 @@ docker-run: ### Run specified image - mandatory: IMAGE; optional: CMD,SH=true,DI make docker-config > /dev/null 2>&1 container=$$([ -n "$(CONTAINER)" ] && echo $(CONTAINER) || echo $$(echo '$(IMAGE)' | md5sum | cut -c1-7)-$(BUILD_COMMIT_HASH)-$(BUILD_ID)-$$(date --date=$$(date -u +"%Y-%m-%dT%H:%M:%S%z") -u +"%Y%m%d%H%M%S" 2> /dev/null)-$$(make secret-random LENGTH=8)) if [[ ! "$(SH)" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$$ ]]; then - docker run --interactive $(_TTY) --rm \ + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ --name $$container \ --user $$(id -u):$$(id -g) \ --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM)") \ @@ -421,7 +430,7 @@ docker-run-editorconfig: ### Run editorconfig container - optional: DIR=[working make docker-config > /dev/null 2>&1 image=$$([ -n "$(IMAGE)" ] && echo $(IMAGE) || echo mstruebing/editorconfig-checker:$(DOCKER_EDITORCONFIG_CHECKER_VERSION)) container=$$([ -n "$(CONTAINER)" ] && echo $(CONTAINER) || echo editorconfig-$(BUILD_COMMIT_HASH)-$(BUILD_ID)-$$(date --date=$$(date -u +"%Y-%m-%dT%H:%M:%S%z") -u +"%Y%m%d%H%M%S" 2> /dev/null)-$$(make secret-random LENGTH=8)) - docker run --interactive $(_TTY) --rm \ + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ --name $$container \ --user $$(id -u):$$(id -g) \ --volume $$([ -n "$(DIR)" ] && echo $(abspath $(DIR)) || echo $(PWD)):/check \ @@ -527,7 +536,7 @@ docker-run-python: ### Run python container - mandatory: CMD; optional: SH=true, image=$$([ -n "$(IMAGE)" ] && echo $(IMAGE) || echo python:$(DOCKER_PYTHON_VERSION)) container=$$([ -n "$(CONTAINER)" ] && echo $(CONTAINER) || echo python-$(BUILD_COMMIT_HASH)-$(BUILD_ID)-$$(date --date=$$(date -u +"%Y-%m-%dT%H:%M:%S%z") -u +"%Y%m%d%H%M%S" 2> /dev/null)-$$(make secret-random LENGTH=8)) if [[ ! "$(SH)" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$$ ]]; then - docker run --interactive $(_TTY) --rm \ + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ --name $$container \ --user $$(id -u):$$(id -g) \ --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM)") \ @@ -545,7 +554,7 @@ docker-run-python: ### Run python container - mandatory: CMD; optional: SH=true, $$image \ $(CMD) else - docker run --interactive $(_TTY) --rm \ + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ --name $$container \ --user $$(id -u):$$(id -g) \ --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM)") \ @@ -590,7 +599,7 @@ docker-run-terraform: ### Run terraform container - mandatory: CMD; optional: DI make docker-config > /dev/null 2>&1 image=$$([ -n "$(IMAGE)" ] && echo $(IMAGE) || echo hashicorp/terraform:$(DOCKER_TERRAFORM_VERSION)) container=$$([ -n "$(CONTAINER)" ] && echo $(CONTAINER) || echo terraform-$(BUILD_COMMIT_HASH)-$(BUILD_ID)-$$(date --date=$$(date -u +"%Y-%m-%dT%H:%M:%S%z") -u +"%Y%m%d%H%M%S" 2> /dev/null)-$$(make secret-random LENGTH=8)) - docker run --interactive $(_TTY) --rm \ + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ --name $$container \ --user $$(id -u):$$(id -g) \ --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM)") \ @@ -607,9 +616,9 @@ docker-run-terraform: ### Run terraform container - mandatory: CMD; optional: DI docker-run-terraform-tfsec: ### Run terraform tfsec container - optional: DIR,ARGS=[Docker args],VARS_FILE=[Makefile vars file],IMAGE=[image name],CONTAINER=[container name]; SEE: https://github.com/tfsec/tfsec make docker-config > /dev/null 2>&1 - image=$$([ -n "$(IMAGE)" ] && echo $(IMAGE) || echo tfsec/tfsec:$(DOCKER_TERRAFORM_TFSEC_VERSION)) + image=$$([ -n "$(IMAGE)" ] && echo $(IMAGE) || echo aquasec/tfsec:$(DOCKER_TERRAFORM_TFSEC_VERSION)) container=$$([ -n "$(CONTAINER)" ] && echo $(CONTAINER) || echo tfsec-$(BUILD_COMMIT_HASH)-$(BUILD_ID)-$$(date --date=$$(date -u +"%Y-%m-%dT%H:%M:%S%z") -u +"%Y%m%d%H%M%S" 2> /dev/null)-$$(make secret-random LENGTH=8)) - docker run --interactive $(_TTY) --rm \ + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ --name $$container \ --user $$(id -u):$$(id -g) \ --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM)") \ @@ -624,11 +633,11 @@ docker-run-terraform-tfsec: ### Run terraform tfsec container - optional: DIR,AR $$image \ . -docker-run-terraform-checkov: ### Run terraform checkov container - optional: DIR,ARGS=[Docker args],VARS_FILE=[Makefile vars file],IMAGE=[image name],CONTAINER=[container name]; SEE: https://github.com/bridgecrewio/checkov +docker-run-checkov: ### Run checkov container - optional: DIR,ARGS=[Docker args],VARS_FILE=[Makefile vars file],IMAGE=[image name],CONTAINER=[container name]; SEE: https://github.com/bridgecrewio/checkov make docker-config > /dev/null 2>&1 - image=$$([ -n "$(IMAGE)" ] && echo $(IMAGE) || echo bridgecrew/checkov:$(DOCKER_TERRAFORM_CHECKOV_VERSION)) + image=$$([ -n "$(IMAGE)" ] && echo $(IMAGE) || echo bridgecrew/checkov:$(DOCKER_CHECKOV_VERSION)) container=$$([ -n "$(CONTAINER)" ] && echo $(CONTAINER) || echo tfsec-$(BUILD_COMMIT_HASH)-$(BUILD_ID)-$$(date --date=$$(date -u +"%Y-%m-%dT%H:%M:%S%z") -u +"%Y%m%d%H%M%S" 2> /dev/null)-$$(make secret-random LENGTH=8)) - docker run --interactive $(_TTY) --rm \ + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ --name $$container \ --user $$(id -u):$$(id -g) \ --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM)") \ @@ -641,7 +650,7 @@ docker-run-terraform-checkov: ### Run terraform checkov container - optional: DI --workdir /project/$(shell echo $(abspath $(DIR)) | sed "s;$(PROJECT_DIR);;g") \ $(ARGS) \ $$image \ - --directory /project/$(shell echo $(abspath $(DIR)) | sed "s;$(PROJECT_DIR);;g") + --directory /project/$(shell echo $(abspath $(DIR)) | sed "s;$(PROJECT_DIR);;g") $(CHECKOV_OPTS) docker-run-terraform-compliance: ### Run terraform compliance container - mandatory: CMD=[-p file -f repo]; optional: DIR,ARGS=[Docker args],VARS_FILE=[Makefile vars file],IMAGE=[image name],CONTAINER=[container name]; SEE: https://github.com/terraform-compliance/cli make docker-config > /dev/null 2>&1 @@ -699,6 +708,10 @@ docker-run-postgres: ### Run postgres container - mandatory: CMD; optional: DIR, docker-run-tools: ### Run tools (Python) container - mandatory: CMD; optional: SH=true,DIR,ARGS=[Docker args],LIB_VOLUME_MOUNT=true,VARS_FILE=[Makefile vars file],IMAGE=[image name],CONTAINER=[container name] make docker-config > /dev/null 2>&1 + if [ ! -z $(CODEBUILD_BUILD_ID) ]; then + $(CMD) + exit 0 + fi mkdir -p $(TMP_DIR)/.python/pip/{cache,packages} mkdir -p $(HOME)/.aws lib_volume_mount=$$(([ $(BUILD_ID) -eq 0 ] || [ "$(LIB_VOLUME_MOUNT)" == true ]) && echo "--volume $(TMP_DIR)/.python/pip/cache:/tmp/.cache/pip --volume $(TMP_DIR)/.python/pip/packages:/tmp/.packages" ||:) @@ -706,7 +719,7 @@ docker-run-tools: ### Run tools (Python) container - mandatory: CMD; optional: S container=$$([ -n "$(CONTAINER)" ] && echo $(CONTAINER) || echo tools-$(BUILD_COMMIT_HASH)-$(BUILD_ID)-$$(date --date=$$(date -u +"%Y-%m-%dT%H:%M:%S%z") -u +"%Y%m%d%H%M%S" 2> /dev/null)-$$(make secret-random LENGTH=8)) make docker-image-pull-or-build NAME=tools VERSION=$(DOCKER_LIBRARY_TOOLS_VERSION) >&2 if [[ ! "$(SH)" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$$ ]]; then - docker run --interactive $(_TTY) --rm \ + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ --name $$container \ --user $$(id -u):$$(id -g) \ --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM)") \ @@ -729,7 +742,7 @@ docker-run-tools: ### Run tools (Python) container - mandatory: CMD; optional: S $$image \ $(CMD) else - docker run --interactive $(_TTY) --rm \ + $(DOCKER_CMD) run --interactive $(_TTY) --rm \ --name $$container \ --user $$(id -u):$$(id -g) \ --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM)") \ @@ -797,6 +810,8 @@ _docker-get-dir: echo $(DOCKER_CUSTOM_DIR)/$(NAME) elif [ -d $(DOCKER_LIB_IMAGE_DIR)/$(NAME) ]; then echo $(DOCKER_LIB_IMAGE_DIR)/$(NAME) + elif [ -d $(DOCKER_DIR)/$(GENERIC_IMAGE_NAME) ] && [ ! -z $(GENERIC_IMAGE_NAME) ] ; then + echo $(DOCKER_DIR)/$(GENERIC_IMAGE_NAME) else echo $(DOCKER_DIR)/$(NAME) fi @@ -822,14 +837,14 @@ _docker-get-login-password: _docker-get-docker-compose-yml: yml=$(or $(YML), $(DOCKER_COMPOSE_YML)) - # if [ "$(BUILD_ID)" != 0 ]; then - # make -s docker-run-tools ARGS="--env BUILD_ID=$(BUILD_ID)" CMD=" \ - # $(BIN_DIR_REL)/docker-compose-processor.py \ - # $$(echo $$yml | sed 's;//;/;g' | sed "s;$(PROJECT_DIR);;g") \ - # $(TMP_DIR_REL)/docker-compose-$(BUILD_ID).yml \ - # " - # yml=$(TMP_DIR)/docker-compose-$(BUILD_ID).yml - # fi + if [ "$(BUILD_ID)" != 0 ]; then + make -s docker-run-tools ARGS="--env BUILD_ID=$(BUILD_ID)" CMD=" \ + $(BIN_DIR_REL)/docker-compose-processor.py \ + $$(echo $$yml | sed 's;//;/;g' | sed "s;$(PROJECT_DIR);;g") \ + $(TMP_DIR_REL)/docker-compose-$(BUILD_ID).yml \ + " + yml=$(TMP_DIR)/docker-compose-$(BUILD_ID).yml + fi echo $$yml _docker-is-lib-image: @@ -846,13 +861,13 @@ docker-image-get-digest: ### Get image digest by matching tag pattern - mandato REPO=$$(make _docker-get-reg)/$(NAME) \ TAG=$(or $(VERSION), $(TAG)) -docker-image-find-and-version-as: ### Find image based on git commit hash and tag it - mandatory: VERSION|TAG=[new version/tag],NAME=[image name]; optional: COMMIT=[git commit hash, defaults to HEAD] - commit=$(or $(COMMIT), master) +docker-image-find-and-version-as: ### Find image based on git commit hash and tag it - mandatory: VERSION|TAG=[new version/tag],NAME=[image name]; optional: COMMIT=[git commit hash, defaults to main] + commit=$(or $(COMMIT), $$(make git-branch-get-main-name)) hash=$$(make git-commit-get-hash COMMIT=$$commit) digest=$$(make docker-image-get-digest NAME=$(NAME) TAG=$$hash) make docker-pull NAME=$(NAME) DIGEST=$$digest make docker-tag NAME=$(NAME) DIGEST=$$digest TAG=$(or $(VERSION), $(TAG)) - make docker-push-for-production NAME=$(NAME) TAG=$(or $(VERSION), $(TAG)) + make docker-push NAME=$(NAME) TAG=$(or $(VERSION), $(TAG)) docker-repo-list-tags: ### List repository tags - mandatory: REPO=[repository name] ( diff --git a/build/automation/lib/docker/compose/mockservice.docker-compose.yml b/build/automation/lib/docker/compose/mockservice.docker-compose.yml index 2dac47719..7ab8a6bae 100644 --- a/build/automation/lib/docker/compose/mockservice.docker-compose.yml +++ b/build/automation/lib/docker/compose/mockservice.docker-compose.yml @@ -2,7 +2,7 @@ version: "3.9" services: mockservice: - image: rodolpheche/wiremock:$DOCKER_WIREMOCK_VERSION + image: wiremock/wiremock:$DOCKER_WIREMOCK_VERSION container_name: mockservice volumes: - $PROJECT_DIR/test/contract/mappings/:/home/wiremock/mappings/ diff --git a/build/automation/lib/docker/image/elasticsearch/Dockerfile b/build/automation/lib/docker/image/elasticsearch/Dockerfile index ba215768e..1d88cfa67 100644 --- a/build/automation/lib/docker/image/elasticsearch/Dockerfile +++ b/build/automation/lib/docker/image/elasticsearch/Dockerfile @@ -1,7 +1,7 @@ FROM bitnami/elasticsearch:latest ENV \ DEBIAN_FRONTEND="noninteractive" \ - GOSU_VERSION="1.12" \ + GOSU_VERSION="1.14" \ LANG="C.UTF-8" \ LC_ALL="C.UTF-8" \ SYSTEM_USER_GID="999" \ diff --git a/build/automation/lib/docker/image/elasticsearch/VERSION b/build/automation/lib/docker/image/elasticsearch/VERSION index 30eeb7399..f2385f2bf 100644 --- a/build/automation/lib/docker/image/elasticsearch/VERSION +++ b/build/automation/lib/docker/image/elasticsearch/VERSION @@ -1 +1 @@ -20210530 +20220301 diff --git a/build/automation/lib/docker/image/nginx/Dockerfile b/build/automation/lib/docker/image/nginx/Dockerfile index 0ac643b7a..c1cf545e5 100644 --- a/build/automation/lib/docker/image/nginx/Dockerfile +++ b/build/automation/lib/docker/image/nginx/Dockerfile @@ -4,7 +4,7 @@ FROM nginx:latest as base FROM base as builder ENV \ - GOSU_VERSION="1.12" + GOSU_VERSION="1.14" RUN set -eux && \ \ diff --git a/build/automation/lib/docker/image/nginx/VERSION b/build/automation/lib/docker/image/nginx/VERSION index 30eeb7399..f2385f2bf 100644 --- a/build/automation/lib/docker/image/nginx/VERSION +++ b/build/automation/lib/docker/image/nginx/VERSION @@ -1 +1 @@ -20210530 +20220301 diff --git a/build/automation/lib/docker/image/nginx/assets/sbin/init.d/50-init.sh b/build/automation/lib/docker/image/nginx/assets/sbin/init.d/50-init.sh index 2a261d91e..3f8dcb43f 100644 --- a/build/automation/lib/docker/image/nginx/assets/sbin/init.d/50-init.sh +++ b/build/automation/lib/docker/image/nginx/assets/sbin/init.d/50-init.sh @@ -38,9 +38,14 @@ function _replace_variables() { for str in $(cat $file | grep -Eo "[A-Za-z0-9_]*_TO_REPLACE" | sort | uniq); do key=$(cut -d "=" -f1 <<<"$str" | sed "s/_TO_REPLACE//g") value=$(echo $(eval echo "\$$key")) - [ -z "$value" ] && echo "WARNING: Variable $key has no value in '$file'" || sed -i \ - "s;${key}_TO_REPLACE;${value//&/\\&};g" \ - $file ||: + [ -z "$value" ] && echo "WARNING: Variable $key has no value in '$file'" || ( + # Replace `${VARIABLE_TO_REPLACE}` + sed -i "s;\${${key}_TO_REPLACE};${value//&/\\&};g" $file ||: + # Replace `$VARIABLE_TO_REPLACE` + sed -i "s;\$${key}_TO_REPLACE;${value//&/\\&};g" $file ||: + # Replace `VARIABLE_TO_REPLACE` + sed -i "s;${key}_TO_REPLACE;${value//&/\\&};g" $file ||: + ) done } diff --git a/build/automation/lib/docker/image/node/Dockerfile b/build/automation/lib/docker/image/node/Dockerfile index f29933a1b..53c416e8f 100644 --- a/build/automation/lib/docker/image/node/Dockerfile +++ b/build/automation/lib/docker/image/node/Dockerfile @@ -4,7 +4,7 @@ FROM node:latest as base FROM base as builder ENV \ - GOSU_VERSION="1.12" + GOSU_VERSION="1.14" RUN set -eux && \ \ diff --git a/build/automation/lib/docker/image/node/VERSION b/build/automation/lib/docker/image/node/VERSION index 30eeb7399..f2385f2bf 100644 --- a/build/automation/lib/docker/image/node/VERSION +++ b/build/automation/lib/docker/image/node/VERSION @@ -1 +1 @@ -20210530 +20220301 diff --git a/build/automation/lib/docker/image/node/example/package.json b/build/automation/lib/docker/image/node/example/package.json index 23f8b6cfa..a9829f8e6 100644 --- a/build/automation/lib/docker/image/node/example/package.json +++ b/build/automation/lib/docker/image/node/example/package.json @@ -4,6 +4,6 @@ "description": "Example", "main": "app.js", "dependencies": { - "express": "^4.17.1" + "express": "^4.17.2" } } diff --git a/build/automation/lib/docker/image/pipeline/VERSION b/build/automation/lib/docker/image/pipeline/VERSION index 4e467cc93..f2385f2bf 100644 --- a/build/automation/lib/docker/image/pipeline/VERSION +++ b/build/automation/lib/docker/image/pipeline/VERSION @@ -1 +1 @@ -20211003 +20220301 diff --git a/build/automation/lib/docker/image/postgres/VERSION b/build/automation/lib/docker/image/postgres/VERSION index 4e467cc93..f2385f2bf 100644 --- a/build/automation/lib/docker/image/postgres/VERSION +++ b/build/automation/lib/docker/image/postgres/VERSION @@ -1 +1 @@ -20211003 +20220301 diff --git a/build/automation/lib/docker/image/postgres/assets/sbin/init.d/50-init.sh b/build/automation/lib/docker/image/postgres/assets/sbin/init.d/50-init.sh index 41bc537f2..9a74f0dd6 100644 --- a/build/automation/lib/docker/image/postgres/assets/sbin/init.d/50-init.sh +++ b/build/automation/lib/docker/image/postgres/assets/sbin/init.d/50-init.sh @@ -34,15 +34,20 @@ function _replace_variables() { for str in $(cat $file | grep -Eo "[A-Za-z0-9_]*_TO_REPLACE" | sort | uniq); do key=$(cut -d "=" -f1 <<<"$str" | sed "s/_TO_REPLACE//g") value=$(echo $(eval echo "\$$key")) - [ -z "$value" ] && echo "WARNING: Variable $key has no value in '$file'" || sed -i \ - "s;${key}_TO_REPLACE;${value//&/\\&};g" \ - $file ||: + [ -z "$value" ] && echo "WARNING: Variable $key has no value in '$file'" || ( + # Replace `${VARIABLE_TO_REPLACE}` + sed -i "s;\${${key}_TO_REPLACE};${value//&/\\&};g" $file ||: + # Replace `$VARIABLE_TO_REPLACE` + sed -i "s;\$${key}_TO_REPLACE;${value//&/\\&};g" $file ||: + # Replace `VARIABLE_TO_REPLACE` + sed -i "s;${key}_TO_REPLACE;${value//&/\\&};g" $file ||: + ) done } function am_i_root() { if [ $(id -u) -eq 0 ]; then - true + true else false fi diff --git a/build/automation/lib/docker/image/python-app/VERSION b/build/automation/lib/docker/image/python-app/VERSION index 4e467cc93..f2385f2bf 100644 --- a/build/automation/lib/docker/image/python-app/VERSION +++ b/build/automation/lib/docker/image/python-app/VERSION @@ -1 +1 @@ -20211003 +20220301 diff --git a/build/automation/lib/docker/image/python-app/assets/sbin/init.d/50-init.sh b/build/automation/lib/docker/image/python-app/assets/sbin/init.d/50-init.sh index fdfb9f8a6..43e9a07f3 100644 --- a/build/automation/lib/docker/image/python-app/assets/sbin/init.d/50-init.sh +++ b/build/automation/lib/docker/image/python-app/assets/sbin/init.d/50-init.sh @@ -13,8 +13,8 @@ function set_file_permissions() { function set_application_directory() { if [ -z "$APP" ]; then - dir=$(ls -1 -d */ | head -n 1) - export APP=${dir%?} + dir=$(ls -1 -d */ | head -n 1) + export APP=${dir%?} fi } diff --git a/build/automation/lib/docker/image/python-app/example/requirements.txt b/build/automation/lib/docker/image/python-app/example/requirements.txt index d02e8c3b5..94a0e8344 100644 --- a/build/automation/lib/docker/image/python-app/example/requirements.txt +++ b/build/automation/lib/docker/image/python-app/example/requirements.txt @@ -1 +1 @@ -Django==3.2.7 +Django diff --git a/build/automation/lib/docker/image/python/VERSION b/build/automation/lib/docker/image/python/VERSION index 4e467cc93..f2385f2bf 100644 --- a/build/automation/lib/docker/image/python/VERSION +++ b/build/automation/lib/docker/image/python/VERSION @@ -1 +1 @@ -20211003 +20220301 diff --git a/build/automation/lib/docker/image/tools/Dockerfile b/build/automation/lib/docker/image/tools/Dockerfile index 0e95ff7ef..125bb9cd5 100644 --- a/build/automation/lib/docker/image/tools/Dockerfile +++ b/build/automation/lib/docker/image/tools/Dockerfile @@ -91,12 +91,12 @@ RUN set -eux && \ mv ./jq-linux64 /usr/local/bin/jq && \ \ # Install yq - curl -LO https://github.com/mikefarah/yq/releases/download/v4.13.3/yq_linux_amd64 && \ + curl -LO https://github.com/mikefarah/yq/releases/download/v4.20.2/yq_linux_amd64 && \ chmod +x ./yq_linux_amd64 && \ mv ./yq_linux_amd64 /usr/local/bin/yq && \ \ # Install kubectl - curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.22.2/bin/linux/amd64/kubectl && \ + curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.23.4/bin/linux/amd64/kubectl && \ chmod +x ./kubectl && \ mv ./kubectl /usr/local/bin/kubectl && \ \ diff --git a/build/automation/lib/docker/image/tools/VERSION b/build/automation/lib/docker/image/tools/VERSION index 5da7af371..f2385f2bf 100644 --- a/build/automation/lib/docker/image/tools/VERSION +++ b/build/automation/lib/docker/image/tools/VERSION @@ -1 +1 @@ -20211004 +20220301 diff --git a/build/automation/lib/docker/image/tools/test/goss.yaml b/build/automation/lib/docker/image/tools/test/goss.yaml index f6b01ef57..5fbdf07f3 100644 --- a/build/automation/lib/docker/image/tools/test/goss.yaml +++ b/build/automation/lib/docker/image/tools/test/goss.yaml @@ -43,22 +43,22 @@ command: exit-status: 0 gosu --version: exit-status: 0 - pip list | grep -iE 'awscli-local.*0.15': + pip list | grep -iE 'awscli-local.*0.18': exit-status: 0 timeout: 60000 - pip list | grep -iE 'awscli.*1.20': + pip list | grep -iE 'awscli.*1.22': exit-status: 0 timeout: 60000 - pip list | grep -iE 'black.*21.9': + pip list | grep -iE 'black.*22.1': exit-status: 0 timeout: 60000 - pip list | grep -iE 'boto3.*1.18': + pip list | grep -iE 'boto3.*1.21': exit-status: 0 timeout: 60000 - pip list | grep -iE 'diagrams.*0.20': + pip list | grep -iE 'diagrams.*0.21': exit-status: 0 timeout: 60000 - pip list | grep -iE 'requests.*2.26': + pip list | grep -iE 'requests.*2.27': exit-status: 0 timeout: 60000 aws-iam-authenticator version: diff --git a/build/automation/lib/git.mk b/build/automation/lib/git.mk index 27107969f..bf2d704fa 100644 --- a/build/automation/lib/git.mk +++ b/build/automation/lib/git.mk @@ -37,12 +37,12 @@ git-check-if-branch-name-is-correct: ### Check if the branch name meets the acce git-check-if-commit-msg-is-correct: ### Check if the commit message meets the accepted convention - optional: BUILD_BRANCH,BUILD_COMMIT_MESSAGE if [[ $(BUILD_BRANCH) =~ $(GIT_BRANCH_PATTERN_MAIN) ]] && \ - [[ "$$(echo '$(BUILD_COMMIT_MESSAGE)' | head -1)" =~ $(GIT_COMMIT_MESSAGE_PATTERN_MAIN) ]] && \ - [ "$$(echo '$(BUILD_COMMIT_MESSAGE)' | head -1 | wc -m)" -le $(GIT_COMMIT_MESSAGE_MAX_LENGTH) ]; then + [[ "$$(echo '$(BUILD_COMMIT_MESSAGE)' | sed s/\'//g | head -1)" =~ $(GIT_COMMIT_MESSAGE_PATTERN_MAIN) ]] && \ + [ "$$(echo '$(BUILD_COMMIT_MESSAGE)' | sed s/\'//g | head -1 | wc -m)" -le $(GIT_COMMIT_MESSAGE_MAX_LENGTH) ]; then echo true elif ! [[ $(BUILD_BRANCH) =~ $(GIT_BRANCH_PATTERN_MAIN) ]] && \ - [[ "$$(echo '$(BUILD_COMMIT_MESSAGE)' | head -1)" =~ $(GIT_COMMIT_MESSAGE_PATTERN_ADDITIONAL) ]] && \ - [ "$$(echo '$(BUILD_COMMIT_MESSAGE)' | head -1 | wc -m)" -le $(GIT_COMMIT_MESSAGE_MAX_LENGTH) ]; then + [[ "$$(echo '$(BUILD_COMMIT_MESSAGE)' | sed s/\'//g | head -1)" =~ $(GIT_COMMIT_MESSAGE_PATTERN_ADDITIONAL) ]] && \ + [ "$$(echo '$(BUILD_COMMIT_MESSAGE)' | sed s/\'//g | head -1 | wc -m)" -le $(GIT_COMMIT_MESSAGE_MAX_LENGTH) ]; then echo true else echo false @@ -101,8 +101,8 @@ git-secrets-scan-repo-files: ### Scan repository files for any secrets git-check-if-commit-changed-directory: ### Check if any file changed in the given directory - mandatory: DIR=[directory]; optional: PRECOMMIT=true; return: true|false if [ "$(PRECOMMIT)" == true ]; then compare_to=HEAD - elif [ "$(BUILD_BRANCH)" != master ]; then - compare_to=master + elif [ "$(BUILD_BRANCH)" != main ] && [ "$(BUILD_BRANCH)" != master ]; then + compare_to=$$(make git-branch-get-main-name) else compare_to=HEAD^ fi @@ -119,18 +119,18 @@ git-commit-get-message git-msg: ### Get commit message - optional: COMMIT=[comm # ============================================================================== -git-tag-is-environment-deployment: ### Check if a commit is tagged as environment deployment - mandatory: PROFILE=[profile name]; optional: COMMIT=[commit, defaults to master]; return: true|false - commit=$(or $(COMMIT), master) +git-tag-is-environment-deployment: ### Check if a commit is tagged as environment deployment - mandatory: PROFILE=[profile name]; optional: COMMIT=[commit, defaults to main]; return: true|false + commit=$(or $(COMMIT), $$(make git-branch-get-main-name)) (git show-ref --tags -d | grep $$(git rev-parse $$commit) | sed -e 's;.* refs/tags/;;' -e 's;\^{};;' | grep -- -$(ENVIRONMENT)$$) > /dev/null 2>&1 && echo true || echo false -git-tag-create: ### Tag a commit - mandatory: TAG=[tag name]; optional: COMMIT=[commit, defaults to master] - commit=$(or $(COMMIT), master) +git-tag-create: ### Tag a commit - mandatory: TAG=[tag name]; optional: COMMIT=[commit, defaults to main] + commit=$(or $(COMMIT), $$(make git-branch-get-main-name)) git tag $(TAG) $$commit git push origin $(TAG) -git-tag-create-environment-deployment: ### Tag environment deployment as `[YYYYmmddHHMMSS]-[env]` - mandatory: PROFILE=[profile name]; optional: COMMIT=[release candidate tag name, defaults to master] +git-tag-create-environment-deployment: ### Tag environment deployment as `[YYYYmmddHHMMSS]-[env]` - mandatory: PROFILE=[profile name]; optional: COMMIT=[release candidate tag name, defaults to main] [ $(PROFILE) == local ] && (echo "ERROR: Please, specify the PROFILE"; exit 1) - commit=$(or $(COMMIT), master) + commit=$(or $(COMMIT), $$(make git-branch-get-main-name)) tag=$(BUILD_TIMESTAMP)-$(ENVIRONMENT) make git-tag-create TAG=$$tag COMMIT=$$commit @@ -145,22 +145,26 @@ git-tag-get-environment-deployment: ### Get the latest environment deployment t git-tag-get-latest: ### Get the latest tag on the current branch - return [YYYYmmddHHMMSS]-[*] git tag --sort version:refname | grep '^[0-9]*'| sort -r | head -n 1 -git-tag-list: ### List tags of a commit - optional: COMMIT=[commit, defaults to master],PROFILE=[profile name] - commit=$(or $(COMMIT), master) +git-tag-list: ### List tags of a commit - optional: COMMIT=[commit, defaults to main],PROFILE=[profile name] + commit=$(or $(COMMIT), $$(make git-branch-get-main-name)) tags=$$(git show-ref --tags -d | grep $$(git rev-parse $$commit) | sed -e 's;.* refs/tags/;;' -e 's;\^{};;' | grep -Eo ^[0-9]*-[a-z]*$$ ||:) [ $(PROFILE) != local ] && tags=$$(echo "$$tags" | grep -- -$(ENVIRONMENT)$$) echo "$$tags" -git-tag-clear: ### Remove tags from the specified commit - optional: COMMIT=[commit, defaults to master] - commit=$(or $(COMMIT), master) +git-tag-clear: ### Remove tags from the specified commit - optional: COMMIT=[commit, defaults to main] + commit=$(or $(COMMIT), $$(make git-branch-get-main-name)) for tag in $$(git show-ref --tags -d | grep $$(git rev-parse $$commit) | sed -e 's;.* refs/tags/;;' -e 's;\^{};;' | grep -Eo ^[0-9]*-[a-z]*$$); do git tag -d $$tag git push --delete origin $$tag 2> /dev/null ||: done +git-branch-get-main-name: ### Get the name of the main branch + git rev-parse --verify main > /dev/null 2>&1 && echo main || echo master + # ============================================================================== .SILENT: \ + git-branch-get-main-name \ git-check-if-branch-name-is-correct \ git-check-if-commit-msg-is-correct \ git-check-if-pull-request-title-is-correct \ diff --git a/build/automation/lib/jenkins/README.md b/build/automation/lib/jenkins/README.md index f92e7bbc5..d14c1287e 100644 --- a/build/automation/lib/jenkins/README.md +++ b/build/automation/lib/jenkins/README.md @@ -23,7 +23,7 @@ Here are the items to consider and a pattern for setting up a Jenkins pipeline. - Branch Sources - Git - Behaviours: - `Discover branches` - `Check out to matching local branch` - - `Filter by name (with wildcards)`, e.g. `master`, `task/*` + - `Filter by name (with wildcards)`, e.g. `main`, `task/*` - Build Configuration - Script Path: `build/jenkins/Jenkinsfile.development|test|tag|production|cleanup` For production diff --git a/build/automation/lib/localstack.mk b/build/automation/lib/localstack.mk index fba28af24..598b9af93 100644 --- a/build/automation/lib/localstack.mk +++ b/build/automation/lib/localstack.mk @@ -1,5 +1,5 @@ LOCALSTACK_HOST = localstack.$(PROJECT_NAME_SHORT).local -LOCALSTACK_VERSION = 0.12.18 +LOCALSTACK_VERSION = 0.14.3 localstack-start: project-config _localstack-copy-ssl-certificate ### Start localstack mkdir -p $(TMP_DIR)/localstack diff --git a/build/automation/lib/macos.mk b/build/automation/lib/macos.mk index 70ade188f..f24a96546 100644 --- a/build/automation/lib/macos.mk +++ b/build/automation/lib/macos.mk @@ -27,7 +27,7 @@ macos-update:: ### Update all currently installed development dependencies brew update brew upgrade ||: brew tap buo/cask-upgrade - brew cu --all --yes + brew cu --all --yes ||: macos-install-essential:: ### Install essential development dependencies - optional: REINSTALL=true export HOMEBREW_NO_AUTO_UPDATE=1 @@ -82,6 +82,7 @@ macos-install-essential:: ### Install essential development dependencies - optio brew $$install pyenv-which-ext ||: brew $$install python@$(PYTHON_VERSION_MAJOR).$(PYTHON_VERSION_MINOR) ||: brew $$install readline ||: + brew $$install remotemobprogramming/brew/mob ||: brew $$install shellcheck ||: brew $$install sqlite3 ||: brew $$install tmux ||: @@ -122,6 +123,7 @@ macos-install-additional:: ### Install additional development dependencies - opt brew $$install --cask keepingyouawake ||: brew $$install --cask nosql-workbench ||: brew $$install --cask postman ||: + brew $$install --cask sourcetree ||: brew $$install --cask spectacle ||: brew $$install --cask tunnelblick ||: # Protoman @@ -162,7 +164,7 @@ macos-install-corporate:: ### Install corporate dependencies - optional: REINSTA brew $$install --cask microsoft-teams ||: brew $$install --cask slack ||: brew $$install --cask vmware-horizon-client ||: - brew $$install --cask avast-security ||: # https://support.avast.com/en-gb/article/Install-Mac-Security/ + brew $$install --cask avast-security ||: # https://support.avast.com/en-gb/article/Install-Mac-Security/ macos-install-recommended:: ### Install recommended dependencies - optional: REINSTALL=true export HOMEBREW_NO_AUTO_UPDATE=1 @@ -180,12 +182,12 @@ macos-install-recommended:: ### Install recommended dependencies - optional: REI brew $$install --cask hammerspoon ||: brew $$install --cask istat-menus ||: brew $$install --cask karabiner-elements ||: - brew $$install --cask mindnode-pro ||: + brew $$install --cask nordvpn ||: brew $$install --cask raindropio ||: - brew $$install --cask sourcetree ||: brew $$install --cask tripmode ||: brew $$install --cask vlc ||: brew $$install --cask wifi-explorer ||: + mas list | grep MindNode || ( mas install $$(mas search MindNode | head -n 1 | awk '{ print $$1 }') && mas upgrade $$(mas list | grep MindNode | awk '{ print $$1 }') ) ||: macos-config:: ### Configure development dependencies make \ @@ -392,85 +394,85 @@ _macos-config-visual-studio-code: # *** Install extensions *** # # PHP - code --force --install-extension bmewburn.vscode-intelephense-client # PHP support - code --force --install-extension felixfbecker.php-debug # PHP support + code --force --install-extension bmewburn.vscode-intelephense-client ||: # PHP support + code --force --install-extension xdebug.php-debug ||: # PHP support # - code --force --install-extension alefragnani.bookmarks - code --force --install-extension alefragnani.project-manager - code --force --install-extension alexkrechik.cucumberautocomplete - code --force --install-extension amazonwebservices.aws-toolkit-vscode - code --force --install-extension ban.spellright - code --force --install-extension christian-kohler.npm-intellisense - code --force --install-extension christian-kohler.path-intellisense - code --force --install-extension coenraads.bracket-pair-colorizer - code --force --install-extension davidanson.vscode-markdownlint - code --force --install-extension dbaeumer.vscode-eslint - code --force --install-extension donjayamanne.githistory - code --force --install-extension dsznajder.es7-react-js-snippets - code --force --install-extension eamodio.gitlens - code --force --install-extension editorconfig.editorconfig - code --force --install-extension eg2.vscode-npm-script - code --force --install-extension emeraldwalk.runonsave - code --force --install-extension esbenp.prettier-vscode - code --force --install-extension ffaraone.pyfilesgen - code --force --install-extension formulahendry.code-runner - code --force --install-extension fosshaas.fontsize-shortcuts - code --force --install-extension gabrielbb.vscode-lombok - code --force --install-extension gruntfuggly.todo-tree - code --force --install-extension hashicorp.terraform - code --force --install-extension hediet.vscode-drawio - code --force --install-extension humao.rest-client - code --force --install-extension jebbs.plantuml - code --force --install-extension johnpapa.vscode-peacock - code --force --install-extension mhutchie.git-graph - code --force --install-extension mrmlnc.vscode-apache - code --force --install-extension ms-azuretools.vscode-docker - code --force --install-extension ms-python.python - code --force --install-extension ms-python.vscode-pylance - code --force --install-extension ms-toolsai.jupyter - code --force --install-extension ms-vsliveshare.vsliveshare-pack - code --force --install-extension msjsdiag.debugger-for-chrome - code --force --install-extension msjsdiag.vscode-react-native - code --force --install-extension mushan.vscode-paste-image - code --force --install-extension nicolasvuillamy.vscode-groovy-lint - code --force --install-extension oderwat.indent-rainbow - code --force --install-extension pivotal.vscode-spring-boot - code --force --install-extension redhat.java - code --force --install-extension redhat.vscode-yaml - code --force --install-extension shengchen.vscode-checkstyle - code --force --install-extension sonarsource.sonarlint-vscode - code --force --install-extension streetsidesoftware.code-spell-checker - code --force --install-extension techer.open-in-browser - code --force --install-extension timonwong.shellcheck - code --force --install-extension tomoki1207.pdf - code --force --install-extension visualstudioexptteam.vscodeintellicode - code --force --install-extension vscjava.vscode-java-pack - code --force --install-extension vscjava.vscode-spring-boot-dashboard - code --force --install-extension vscjava.vscode-spring-initializr - code --force --install-extension vscode-icons-team.vscode-icons - code --force --install-extension vsls-contrib.codetour - code --force --install-extension vsls-contrib.gistfs - code --force --install-extension wayou.vscode-todo-highlight - code --force --install-extension xabikos.javascriptsnippets - code --force --install-extension yzhang.dictionary-completion - code --force --install-extension yzhang.markdown-all-in-one + code --force --install-extension alefragnani.bookmarks ||: + code --force --install-extension alefragnani.project-manager ||: + code --force --install-extension alexkrechik.cucumberautocomplete ||: + code --force --install-extension amazonwebservices.aws-toolkit-vscode ||: + code --force --install-extension ban.spellright ||: + code --force --install-extension christian-kohler.npm-intellisense ||: + code --force --install-extension christian-kohler.path-intellisense ||: + code --force --install-extension davidanson.vscode-markdownlint ||: + code --force --install-extension dbaeumer.vscode-eslint ||: + code --force --install-extension donjayamanne.githistory ||: + code --force --install-extension dsznajder.es7-react-js-snippets ||: + code --force --install-extension eamodio.gitlens ||: + code --force --install-extension editorconfig.editorconfig ||: + code --force --install-extension eg2.vscode-npm-script ||: + code --force --install-extension emeraldwalk.runonsave ||: + code --force --install-extension esbenp.prettier-vscode ||: + code --force --install-extension ffaraone.pyfilesgen ||: + code --force --install-extension formulahendry.code-runner ||: + code --force --install-extension fosshaas.fontsize-shortcuts ||: + code --force --install-extension gabrielbb.vscode-lombok ||: + code --force --install-extension github.vscode-pull-request-github + code --force --install-extension gruntfuggly.todo-tree ||: + code --force --install-extension hashicorp.terraform ||: + code --force --install-extension hediet.vscode-drawio ||: + code --force --install-extension humao.rest-client ||: + code --force --install-extension jebbs.plantuml ||: + code --force --install-extension johnpapa.vscode-peacock ||: + code --force --install-extension mhutchie.git-graph ||: + code --force --install-extension mrmlnc.vscode-apache ||: + code --force --install-extension ms-azuretools.vscode-docker ||: + code --force --install-extension ms-python.python ||: + code --force --install-extension ms-python.vscode-pylance ||: + code --force --install-extension ms-toolsai.jupyter ||: + code --force --install-extension ms-vsliveshare.vsliveshare-pack ||: + code --force --install-extension msjsdiag.debugger-for-chrome ||: + code --force --install-extension msjsdiag.vscode-react-native ||: + code --force --install-extension mushan.vscode-paste-image ||: + code --force --install-extension nicolasvuillamy.vscode-groovy-lint ||: + code --force --install-extension oderwat.indent-rainbow ||: + code --force --install-extension pivotal.vscode-spring-boot ||: + code --force --install-extension redhat.java ||: + code --force --install-extension redhat.vscode-yaml ||: + code --force --install-extension shengchen.vscode-checkstyle ||: + code --force --install-extension sonarsource.sonarlint-vscode ||: + code --force --install-extension streetsidesoftware.code-spell-checker ||: + code --force --install-extension techer.open-in-browser ||: + code --force --install-extension timonwong.shellcheck ||: + code --force --install-extension tomoki1207.pdf ||: + code --force --install-extension visualstudioexptteam.vscodeintellicode ||: + code --force --install-extension vscjava.vscode-java-pack ||: + code --force --install-extension vscjava.vscode-spring-boot-dashboard ||: + code --force --install-extension vscjava.vscode-spring-initializr ||: + code --force --install-extension vscode-icons-team.vscode-icons ||: + code --force --install-extension vsls-contrib.codetour ||: + code --force --install-extension vsls-contrib.gistfs ||: + code --force --install-extension wayou.vscode-todo-highlight ||: + code --force --install-extension xabikos.javascriptsnippets ||: + code --force --install-extension yzhang.dictionary-completion ||: + code --force --install-extension yzhang.markdown-all-in-one ||: # # *** Install themes *** # - code --force --install-extension ahmadawais.shades-of-purple - code --force --install-extension akamud.vscode-theme-onedark - code --force --install-extension arcticicestudio.nord-visual-studio-code - code --force --install-extension dracula-theme.theme-dracula - code --force --install-extension equinusocio.vsc-material-theme - code --force --install-extension ginfuru.ginfuru-better-solarized-dark-theme - code --force --install-extension johnpapa.winteriscoming - code --force --install-extension liviuschera.noctis - code --force --install-extension ryanolsonx.solarized - code --force --install-extension sdras.night-owl - code --force --install-extension smlombardi.slime - code --force --install-extension vangware.dark-plus-material - code --force --install-extension wesbos.theme-cobalt2 - code --force --install-extension zhuangtongfa.material-theme + code --force --install-extension ahmadawais.shades-of-purple ||: + code --force --install-extension akamud.vscode-theme-onedark ||: + code --force --install-extension arcticicestudio.nord-visual-studio-code ||: + code --force --install-extension dracula-theme.theme-dracula ||: + code --force --install-extension equinusocio.vsc-material-theme ||: + code --force --install-extension ginfuru.ginfuru-better-solarized-dark-theme ||: + code --force --install-extension johnpapa.winteriscoming ||: + code --force --install-extension liviuschera.noctis ||: + code --force --install-extension ryanolsonx.solarized ||: + code --force --install-extension sdras.night-owl ||: + code --force --install-extension smlombardi.slime ||: + code --force --install-extension vangware.dark-plus-material ||: + code --force --install-extension wesbos.theme-cobalt2 ||: + code --force --install-extension zhuangtongfa.material-theme ||: # List them all code --list-extensions --show-versions # Copy user key bindings diff --git a/build/automation/lib/macos/scripts/macos-config.sh b/build/automation/lib/macos/scripts/macos-config.sh index d63c76e84..99aec1dd0 100755 --- a/build/automation/lib/macos/scripts/macos-config.sh +++ b/build/automation/lib/macos/scripts/macos-config.sh @@ -16,12 +16,12 @@ function clone() { fi cd "$HOME/.make-devops" git pull --all - git checkout ${BRANCH_NAME:-master} + git checkout ${BRANCH_NAME:-main} } function download() { curl -L \ - "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-master}?$(date +%s)" \ + "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-main}?$(date +%s)" \ -o /tmp/make-devops.tar.gz tar -zxf /tmp/make-devops.tar.gz -C /tmp rm -rf \ diff --git a/build/automation/lib/macos/scripts/macos-fix.sh b/build/automation/lib/macos/scripts/macos-fix.sh index 8879eeb08..289b16682 100755 --- a/build/automation/lib/macos/scripts/macos-fix.sh +++ b/build/automation/lib/macos/scripts/macos-fix.sh @@ -16,12 +16,12 @@ function clone() { fi cd "$HOME/.make-devops" git pull --all - git checkout ${BRANCH_NAME:-master} + git checkout ${BRANCH_NAME:-main} } function download() { curl -L \ - "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-master}?$(date +%s)" \ + "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-main}?$(date +%s)" \ -o /tmp/make-devops.tar.gz tar -zxf /tmp/make-devops.tar.gz -C /tmp rm -rf \ diff --git a/build/automation/lib/macos/scripts/macos-install-additional.sh b/build/automation/lib/macos/scripts/macos-install-additional.sh index 13f93c954..e622afe67 100755 --- a/build/automation/lib/macos/scripts/macos-install-additional.sh +++ b/build/automation/lib/macos/scripts/macos-install-additional.sh @@ -16,12 +16,12 @@ function clone() { fi cd "$HOME/.make-devops" git pull --all - git checkout ${BRANCH_NAME:-master} + git checkout ${BRANCH_NAME:-main} } function download() { curl -L \ - "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-master}?$(date +%s)" \ + "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-main}?$(date +%s)" \ -o /tmp/make-devops.tar.gz tar -zxf /tmp/make-devops.tar.gz -C /tmp rm -rf \ diff --git a/build/automation/lib/macos/scripts/macos-install-corporate.sh b/build/automation/lib/macos/scripts/macos-install-corporate.sh index 020afda68..3fe4569fd 100755 --- a/build/automation/lib/macos/scripts/macos-install-corporate.sh +++ b/build/automation/lib/macos/scripts/macos-install-corporate.sh @@ -16,12 +16,12 @@ function clone() { fi cd "$HOME/.make-devops" git pull --all - git checkout ${BRANCH_NAME:-master} + git checkout ${BRANCH_NAME:-main} } function download() { curl -L \ - "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-master}?$(date +%s)" \ + "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-main}?$(date +%s)" \ -o /tmp/make-devops.tar.gz tar -zxf /tmp/make-devops.tar.gz -C /tmp rm -rf \ diff --git a/build/automation/lib/macos/scripts/macos-install-essential.sh b/build/automation/lib/macos/scripts/macos-install-essential.sh index 0b5aa6aae..4df9d84cf 100755 --- a/build/automation/lib/macos/scripts/macos-install-essential.sh +++ b/build/automation/lib/macos/scripts/macos-install-essential.sh @@ -16,12 +16,12 @@ function clone() { fi cd "$HOME/.make-devops" git pull --all - git checkout ${BRANCH_NAME:-master} + git checkout ${BRANCH_NAME:-main} } function download() { curl -L \ - "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-master}?$(date +%s)" \ + "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-main}?$(date +%s)" \ -o /tmp/make-devops.tar.gz tar -zxf /tmp/make-devops.tar.gz -C /tmp rm -rf \ diff --git a/build/automation/lib/macos/scripts/macos-install-recommended.sh b/build/automation/lib/macos/scripts/macos-install-recommended.sh index e447879d3..279643cd3 100755 --- a/build/automation/lib/macos/scripts/macos-install-recommended.sh +++ b/build/automation/lib/macos/scripts/macos-install-recommended.sh @@ -16,12 +16,12 @@ function clone() { fi cd "$HOME/.make-devops" git pull --all - git checkout ${BRANCH_NAME:-master} + git checkout ${BRANCH_NAME:-main} } function download() { curl -L \ - "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-master}?$(date +%s)" \ + "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-main}?$(date +%s)" \ -o /tmp/make-devops.tar.gz tar -zxf /tmp/make-devops.tar.gz -C /tmp rm -rf \ diff --git a/build/automation/lib/macos/scripts/macos-prepare.sh b/build/automation/lib/macos/scripts/macos-prepare.sh index 6ce2bec84..cc75a6719 100755 --- a/build/automation/lib/macos/scripts/macos-prepare.sh +++ b/build/automation/lib/macos/scripts/macos-prepare.sh @@ -16,12 +16,12 @@ function clone() { fi cd "$HOME/.make-devops" git pull --all - git checkout ${BRANCH_NAME:-master} + git checkout ${BRANCH_NAME:-main} } function download() { curl -L \ - "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-master}?$(date +%s)" \ + "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-main}?$(date +%s)" \ -o /tmp/make-devops.tar.gz tar -zxf /tmp/make-devops.tar.gz -C /tmp rm -rf \ diff --git a/build/automation/lib/macos/scripts/macos-setup.sh b/build/automation/lib/macos/scripts/macos-setup.sh index 521792471..67eb0b101 100755 --- a/build/automation/lib/macos/scripts/macos-setup.sh +++ b/build/automation/lib/macos/scripts/macos-setup.sh @@ -16,12 +16,12 @@ function clone() { fi cd "$HOME/.make-devops" git pull --all - git checkout ${BRANCH_NAME:-master} + git checkout ${BRANCH_NAME:-main} } function download() { curl -L \ - "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-master}?$(date +%s)" \ + "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-main}?$(date +%s)" \ -o /tmp/make-devops.tar.gz tar -zxf /tmp/make-devops.tar.gz -C /tmp rm -rf \ diff --git a/build/automation/lib/macos/scripts/macos-update.sh b/build/automation/lib/macos/scripts/macos-update.sh index 0828b59b3..409953243 100755 --- a/build/automation/lib/macos/scripts/macos-update.sh +++ b/build/automation/lib/macos/scripts/macos-update.sh @@ -16,12 +16,12 @@ function clone() { fi cd "$HOME/.make-devops" git pull --all - git checkout ${BRANCH_NAME:-master} + git checkout ${BRANCH_NAME:-main} } function download() { curl -L \ - "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-master}?$(date +%s)" \ + "https://github.com/nhsd-exeter/make-devops/tarball/${BRANCH_NAME:-main}?$(date +%s)" \ -o /tmp/make-devops.tar.gz tar -zxf /tmp/make-devops.tar.gz -C /tmp rm -rf \ diff --git a/build/automation/lib/node.mk b/build/automation/lib/node.mk index de3ac31ad..b1b0bb50c 100644 --- a/build/automation/lib/node.mk +++ b/build/automation/lib/node.mk @@ -1,4 +1,4 @@ -NODE_VERSION = 16.2.0 +NODE_VERSION = 18.3.0 # TODO: Print Node.js version in prompt always when `.nvmrc` file is present, not only if diffrent to global diff --git a/build/automation/lib/postgres.mk b/build/automation/lib/postgres.mk index 0eb248e42..559bd02e2 100644 --- a/build/automation/lib/postgres.mk +++ b/build/automation/lib/postgres.mk @@ -1,8 +1,8 @@ POSTGRES_VERSION_MAJOR = 12 -POSTGRES_VERSION_MINOR = 8 +POSTGRES_VERSION_MINOR = 11 POSTGRES_VERSION = $(POSTGRES_VERSION_MAJOR).$(POSTGRES_VERSION_MINOR) AWS_POSTGRES_VERSION_MAJOR = 12 -AWS_POSTGRES_VERSION_MINOR = 8 +AWS_POSTGRES_VERSION_MINOR = 10 AWS_POSTGRES_VERSION = $(AWS_POSTGRES_VERSION_MAJOR).$(AWS_POSTGRES_VERSION_MINOR) postgres-check-versions: ### Check PostgreSQL versions alignment diff --git a/build/automation/lib/project.mk b/build/automation/lib/project.mk index 64d125f00..82751f863 100644 --- a/build/automation/lib/project.mk +++ b/build/automation/lib/project.mk @@ -142,9 +142,9 @@ project-list-profiles: ### List all the profiles [ $$profile != local ] && echo $$profile ||: done -project-tag-as-environment-deployment: ### Tag environment deployment - mandatory: ARTEFACT|ARTEFACTS=[comma-separated image names],PROFILE=[profile name]; optional: COMMIT=[git release candidate tag name, defaults to master] - [ $(PROFILE) = local ] && (echo "ERROR: Please, specify the PROFILE"; exit 1) - commit=$(or $(COMMIT), master) +project-tag-as-environment-deployment: ### Tag environment deployment - mandatory: ARTEFACT|ARTEFACTS=[comma-separated image names],PROFILE=[profile name]; optional: COMMIT=[git release candidate tag name, defaults to main] + [ $(PROFILE) == local ] && (echo "ERROR: Please, specify the PROFILE"; exit 1) + commit=$(or $(COMMIT), $$(make git-branch-get-main-name)) git_tag=$$(make git-tag-get-environment-deployment COMMIT=$$commit ENVIRONMENT=$(ENVIRONMENT)) for image in $$(echo $(or $(ARTEFACTS), $(ARTEFACT)) | tr "," "\n"); do make docker-image-find-and-version-as \ diff --git a/build/automation/lib/project/template/.github/workflows/code-quality.yml b/build/automation/lib/project/template/.github/workflows/code-quality.yml index d263748c9..b46226007 100644 --- a/build/automation/lib/project/template/.github/workflows/code-quality.yml +++ b/build/automation/lib/project/template/.github/workflows/code-quality.yml @@ -1,7 +1,7 @@ name: "Check code format and quality" on: push: - branches: [master] + branches: [main] pull_request: types: [opened, synchronize, reopened] jobs: diff --git a/build/automation/lib/project/template/.vscode/extensions.json b/build/automation/lib/project/template/.vscode/extensions.json new file mode 100644 index 000000000..c3cfc4972 --- /dev/null +++ b/build/automation/lib/project/template/.vscode/extensions.json @@ -0,0 +1,16 @@ +{ + "recommendations": [ + "ban.spellright", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "johnpapa.vscode-peacock", + "streetsidesoftware.code-spell-checker", + "vscode-icons-team.vscode-icons", + "yzhang.dictionary-completion", + "yzhang.markdown-all-in-one" + ], + "unwantedRecommendations": [ + "googlecloudtools.cloudcode", + "ms-azuretools.vscode-azurefunctions" + ] +} diff --git a/build/automation/lib/project/template/README.md b/build/automation/lib/project/template/README.md index 1226cf63c..e068a300c 100644 --- a/build/automation/lib/project/template/README.md +++ b/build/automation/lib/project/template/README.md @@ -93,13 +93,13 @@ Generate and trust a self-signed certificate that will be used locally to enable Here is the list of the development practices that have to be followed by the team and the individual members: -- Only use single canonical branch **master**. Any intermediate branch significantly increases the maintenance overhead of the repository. -- Apply the git rebase workflow and never merge from master to a task branch. Follow the **squash-rebase-merge** pattern to keep the history linear and clean. +- Only use single canonical branch **main**. Any intermediate branch significantly increases the maintenance overhead of the repository. +- Apply the git rebase workflow and never merge from main to a task branch. Follow the **squash-rebase-merge** pattern to keep the history linear and clean. - Cryptographically sign your commits using **gpg** to ensure its content have not been tampered with. - Format the summary message of your pull request (merge request) using the following pattern **"JIRA-XXX Summary of the change being made"** for complines and clarity as well as to enable tooling to produce release notes automatically. - Announce your PR/MR on the development Slack channel to allow any team member to review it and to share the knowledge. A change can be merged only if all comments have been addressed and it has been **approved by at least one peer**. Make good use of paring/mobbing/swarming practices for collaborative coding. -Before starting any work, please read [CONTRIBUTING.md](documentation/CONTRIBUTING.md) for more detailed instructions. +Before starting any work, please read [Contributing.md](documentation/Contributing.md) for more detailed instructions. ## Development diff --git a/build/automation/lib/project/template/build/automation/var/profile/README.md b/build/automation/lib/project/template/build/automation/var/profile/README.md index 98711359a..be1b3ffc7 100644 --- a/build/automation/lib/project/template/build/automation/var/profile/README.md +++ b/build/automation/lib/project/template/build/automation/var/profile/README.md @@ -7,7 +7,7 @@ Profile is a set of configuration options specific to an environment expressed a ## Defaults * `local` - local development profile -* `dev` - shared development profile that supports automation on every commit to the remote master branch in the CI pipeline or it is used for a cleanup +* `dev` - shared development profile that supports automation on every commit to the remote main branch in the CI pipeline or it is used for a cleanup * `test` - test profile that runs nightly in the CI pipeline * `demo` - production, UX * `live` - production, service diff --git a/build/automation/lib/project/template/documentation/CONTRIBUTING.md b/build/automation/lib/project/template/documentation/Contributing.md similarity index 92% rename from build/automation/lib/project/template/documentation/CONTRIBUTING.md rename to build/automation/lib/project/template/documentation/Contributing.md index 8a0facc0e..b177f4519 100644 --- a/build/automation/lib/project/template/documentation/CONTRIBUTING.md +++ b/build/automation/lib/project/template/documentation/Contributing.md @@ -132,13 +132,13 @@ Upload the public key to your GitHub and GitLab accounts using the links below. Principles to follow - A direct merge to the canonical branch is not allowed and can only be done by creating a pull request (merge request) -- If not stated otherwise the only long-lived branch is master, i.e. canonical branch -- Any new branch should be created from master +- If not stated otherwise the only long-lived branch is main, i.e. canonical branch +- Any new branch should be created from main - The preferred short-lived branch name format is `task/JIRA-XXX_Descriptive_branch_name` - The preferred hotfix branch name format is `bugfix/JIRA-XXX_Descriptive_branch_name` - All commits must be cryptographically signed - Commits should be made often and pushed to the remote -- Use rebase to get the latest commits from the master while working with a short-lived or a bugfix branch +- Use rebase to get the latest commits from the main while working with a short-lived or a bugfix branch - Squash commits when appropriate - Merge commits are not allowed @@ -174,7 +174,7 @@ Contributing to an already existing branch git commit -S -m "Meaningful description of change" git push -Rebasing a branch onto master +Rebasing a branch onto main git checkout task/JIRA-XXX_Descriptive_branch_name git rebase -i HEAD~X # Squash X number of commits, all into one @@ -182,19 +182,19 @@ Rebasing a branch onto master # On the following screen replace pre-inserted comments by a single summary git push --force-with-lease - git checkout master + git checkout main git pull git checkout task/JIRA-XXX_Descriptive_branch_name - git rebase master + git rebase main # Resolve conflicts git add . git rebase --continue git push --force-with-lease -Merging a branch to master - this should be done only in an exceptional circumstance as the proper process is to raise an MR +Merging a branch to main - this should be done only in an exceptional circumstance as the proper process is to raise an MR - git checkout master - git pull --prune # Make sure master is up-to-date + git checkout main + git pull --prune # Make sure main is up-to-date git checkout task/JIRA-XXX_Descriptive_branch_name git pull # Make sure the task branch is up-to-date @@ -202,10 +202,10 @@ Merging a branch to master - this should be done only in an exceptional circumst # When prompted change commit type to `squash` for all the commits except the top one # On the following screen replace pre-inserted comments by a single summary - git rebase master # Rebase the task branch on top of master - git checkout master # Switch to master branch + git rebase main # Rebase the task branch on top of main + git checkout main # Switch to main branch git merge -ff task/JIRA-XXX_Descriptive_branch_name # Fast-forward merge - git push # Push master to remote + git push # Push main to remote git push -d origin task/JIRA-XXX_Descriptive_branch_name # Remove remote branch git branch -d task/JIRA-XXX_Descriptive_branch_name # Remove local branch @@ -252,7 +252,7 @@ Git hooks are located in `build/automation/etc/githooks/scripts` and executed au ### Git tags -Aim at driving more complex deployment workflows by tags with an exception of the master branch where the continuous deployment to a development environment should be enabled by default. +Aim at driving more complex deployment workflows by tags with an exception of the main branch where the continuous deployment to a development environment should be enabled by default. ## Pull request (merge request) @@ -260,7 +260,7 @@ Aim at driving more complex deployment workflows by tags with an exception of th - Ensure all commits will be squashed and the source branch will be removed once the request is accepted - Notify the team on Slack to give your colleagues opportunity to review changes and share the knowledge - If the change has not been pair or mob programmed it must follow the code review process and be approved by at least one peer, all discussions must be resolved -- A merge to master must be squashed and rebased on top, preserving the list of all commit messages +- A merge to main must be squashed and rebased on top, preserving the list of all commit messages ## Code review diff --git a/build/automation/lib/project/template/documentation/ONBOARDING.md b/build/automation/lib/project/template/documentation/Onboarding.md similarity index 74% rename from build/automation/lib/project/template/documentation/ONBOARDING.md rename to build/automation/lib/project/template/documentation/Onboarding.md index 3a3e6195d..4b1ee490e 100644 --- a/build/automation/lib/project/template/documentation/ONBOARDING.md +++ b/build/automation/lib/project/template/documentation/Onboarding.md @@ -23,27 +23,29 @@ This is a generic onboarding template that can be used by any team to help to br ### Corporate Accounts -- Access to the NHSmail account, which is the prerequisite to all the others -- VDI to access Cherwell +- Give access to the NHSmail account which is the prerequisite to all the others +- VDI to access corporate resources like ESR and Cherwell - Microsoft Teams - DoS, SF, PU, Tech Strategy -- Slack - A2SI and Texas workspaces +- Slack - A2SI and Texas (`general` channel) workspaces - [JIRA](https://nhsd-jira.digital.nhs.uk/) - [Confluence](https://nhsd-confluence.digital.nhs.uk/) +- Raise [JIRA](https://nhsd-jira.digital.nhs.uk/secure/RapidBoard.jspa?rapidView=1875) ticket with the Texas team for a new starter ### Technical Setup -- macOS [configuration](https://github.com/nhsd-exeter/make-devops/blob/master/build/automation/lib/macos/README.md) +- Ensure your macOS is [configured](https://github.com/nhsd-exeter/make-devops/blob/master/build/automation/lib/macos/README.md) +- Get the AWS Console and command-line access (MFA must be enabled) - CPaaS Texas VPN access -- [GitLab](https://gitlab.mgmt.texasplatform.uk/) +- [GitHub](https://github.com/nhsd-exeter) & [GitLab](https://gitlab.mgmt.texasplatform.uk/) - [Jenkins](https://jenkins.mgmt.texasplatform.uk/) +- [SonarCloud](https://sonarcloud.io/organizations/nhsd-exeter/) & [SonarQube](https://sonarqube.mgmt.texasplatform.uk/) - [Instana](https://instana-server.mgmt.texasplatform.uk/) -- [GitHub](https://github.com/nhsd-exeter) - [Splunk](https://nhsdigital.splunkcloud.com/) -- AWS - ensure MFA is enabled -- Git projects -- Documentation +- Clone relevant git projects +- read the documentation - [Make DevOps](https://github.com/nhsd-exeter/make-devops) - [Software Engineering Quality Framework](https://github.com/NHSDigital/software-engineering-quality-framework) + - [Delivery Framework](https://github.com/robdale/delivery-framework) ### Organisational Information @@ -55,15 +57,15 @@ This is a generic onboarding template that can be used by any team to help to br ### Before joining -- Get a MacBook, minimum Intel Core i5 4-core processor and 16GM memory -- Install the latest version of the macOS operating system and all the updates unless there are known compatibility issues, usually it is a good practice to wait a couple of weeks after a new major version release +- Get a MacBook, minimum Intel Core i5 4-core processor (or Apple M1) and 16GM memory +- Install the latest version of the macOS operating system and all the updates unless there are known compatibility issues, usually it is a good practice to wait a couple of weeks after a new major version release of the operating system - Encrypt the disk storage - Request the NHSmail account, usually this will be done by a Delivery Manager ### Day 1 and 2 - Arrange a call with an ICT representative to set up the corporate accounts -- Buddy up new starter with last person to get development environment up and running. Make sure they know it’s totally ok to ask all kinds of questions. +- Buddy up new starter with a colleague to get development environment up and running. Make sure they know it’s totally ok to ask all kinds of questions. - The new engineer updates development environment documentation if it’s out of date or confusing. Therefore, they are contributing straight away and the documentation is better as a result. ### Day 3 to 5 diff --git a/build/automation/lib/project/template/documentation/Principles.md b/build/automation/lib/project/template/documentation/Principles.md new file mode 100644 index 000000000..5d9fc4c95 --- /dev/null +++ b/build/automation/lib/project/template/documentation/Principles.md @@ -0,0 +1,11 @@ +# Principles + +Teams in large-scale projects and programmes need to reach agreement on many decisions with leaders, experts, managers and other stakeholders. The need for aligning the work and the process with the rest of the organisation reduces team autonomy. Coordination by architecture is one strategy to handle this challenge. + +Consequently, we did a case study of a large-scale software program consisting of nine teams, to understand how the architecture can enable team autonomy. Initially teams had limited autonomy, because of high task dependencies with other teams and experts. By introducing an architecture based on business domains and APIs, teams got full responsibility for a set of components, and solved the alignment problem by letting other teams access the resources through an API. The new architectural strategy can be understood as structuring by business domains and APIs, instead of features that span the whole code base. + +1. Lead by Intent +2. Stop Starting, Start Finishing +3. Make Work Visible +4. Maintain Sustainable Pace +5. Share Architectural Responsibility diff --git a/build/automation/lib/project/template/documentation/TODO.md b/build/automation/lib/project/template/documentation/TODO.md index 5791bc2dc..1d8ced74b 100644 --- a/build/automation/lib/project/template/documentation/TODO.md +++ b/build/automation/lib/project/template/documentation/TODO.md @@ -13,7 +13,7 @@ List here all the technical tasks for prioritisation that need to be discussed with the team but are not ready yet to be placed on the backlog. This should form a holistic view of the state of the project and show the direction for incremental development and refactoring of certain areas of the software listed below. The idea behind this list is to ensure backlog hygiene and that it only consists of stories that can be completed within the next two sprints. Therefore, the focus can be changed dynamically depending on the business need. -This document must be discussed regularly with the Tech Lead and considered in the context of the [Engineering Quality Checks](https://github.com/NHSDigital/software-engineering-quality-framework/blob/master/quality-checks.md) provided as a guidance by NHS Digital Product Development directorate. +This document must be discussed regularly with the Tech Lead and considered in the context of the [Engineering Quality Checks](https://github.com/NHSDigital/software-engineering-quality-framework/blob/main/quality-checks.md) provided as a guidance by NHS Digital Product Development directorate. ## Regular tasks diff --git a/build/automation/lib/project/template/project.code-workspace b/build/automation/lib/project/template/project.code-workspace index 6d0cd7a60..4b2d42b2d 100644 --- a/build/automation/lib/project/template/project.code-workspace +++ b/build/automation/lib/project/template/project.code-workspace @@ -1,11 +1,27 @@ { "folders": [ { - "name": "Configuration", + "name": "Make DevOps - Docker Images", + "path": "./build/automation/lib/docker/image" + }, + { + "name": "Make DevOps - Template - Terraform Stacks", + "path": "./build/automation/lib/terraform/template/stacks" + }, + { + "name": "Make DevOps - Template - Terraform Modules", + "path": "./build/automation/lib/terraform/template/modules" + }, + { + "name": "Make DevOps - Template - Project Structure", + "path": "./build/automation/lib/project/template" + }, + { + "name": "Project Configuration", "path": "./build/automation/var" }, { - "name": "[Repository]", + "name": "Project Repository", "path": "." } ], @@ -15,6 +31,7 @@ "editor.bracketPairColorization.enabled": true, "editor.fontSize": 14, "editor.formatOnSave": true, + "editor.guides.bracketPairs": "active", "editor.renderWhitespace": "all", "editor.rulers": [ 120 @@ -22,7 +39,7 @@ "editor.suggestSelection": "recentlyUsedByPrefix", "explorer.autoReveal": true, "explorer.sortOrder": "type", - "extensions.ignoreRecommendations": true, + "extensions.ignoreRecommendations": false, "files.associations": { "Jenkinsfile*": "jenkinsfile" }, @@ -47,7 +64,7 @@ "java.completion.enabled": true, "java.configuration.updateBuildConfiguration": "automatic", "java.format.enabled": false, - "java.home": "/Library/Java/JavaVirtualMachines/adoptopenjdk-16.jdk/Contents/Home", + "java.jdt.ls.java.home": "/Library/Java/JavaVirtualMachines/adoptopenjdk-16.jdk/Contents/Home", "php.suggest.basic": false, "php.validate.executablePath": "/usr/bin/php", "python.formatting.blackArgs": [ @@ -60,14 +77,14 @@ "--max-line-length=120" ], "python.linting.flake8Enabled": true, - "python.linting.flake8Path": "~/.pyenv/versions/3.9.7/bin/flake8", + "python.linting.flake8Path": "~/.pyenv/versions/3.10.2/bin/flake8", "python.linting.mypyArgs": [], "python.linting.mypyEnabled": true, - "python.linting.mypyPath": "~/.pyenv/versions/3.9.7/bin/mypy", + "python.linting.mypyPath": "~/.pyenv/versions/3.10.2/bin/mypy", "python.linting.pylintArgs": [], "python.linting.pylintEnabled": true, - "python.linting.pylintPath": "~/.pyenv/versions/3.9.7/bin/pylint", - "python.pythonPath": "~/.pyenv/versions/3.9.7/bin/python", + "python.linting.pylintPath": "~/.pyenv/versions/3.10.2/bin/pylint", + "python.pythonPath": "~/.pyenv/versions/3.10.2/bin/python", "python.terminal.activateEnvironment": false, "search.exclude": { "**/.git": true, @@ -76,8 +93,6 @@ "**/target": true, "**/tmp": true }, - "telemetry.enableCrashReporter": false, - "telemetry.enableTelemetry": false, "terminal.integrated.fontFamily": "Hack Nerd Font", "window.title": "${activeEditorMedium}${separator}${rootName}${separator}${rootPath}", "window.zoomLevel": 0, @@ -177,6 +192,26 @@ "cmd": "/usr/local/bin/google-java-format --replace ${file}" } ] - } + }, + "workbench.colorCustomizations": { + "activityBar.background": "#1accff", + "activityBar.activeBackground": "#1accff", + "activityBar.activeBorder": "#df00ad", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#df00ad", + "activityBarBadge.foreground": "#e7e7e7", + "titleBar.activeBackground": "#00b3e6", + "titleBar.inactiveBackground": "#00b3e699", + "titleBar.activeForeground": "#15202b", + "titleBar.inactiveForeground": "#15202b99", + "statusBar.background": "#00b3e6", + "statusBarItem.hoverBackground": "#008bb3", + "statusBar.foreground": "#15202b", + "sash.hoverBorder": "#1accff", + "statusBarItem.remoteBackground": "#00b3e6", + "statusBarItem.remoteForeground": "#15202b" + }, + "peacock.color": "#00b3e6" } } diff --git a/build/automation/lib/python.mk b/build/automation/lib/python.mk index 0c2472fc0..b0cd57419 100644 --- a/build/automation/lib/python.mk +++ b/build/automation/lib/python.mk @@ -1,24 +1,25 @@ PYTHON_VERSION_MAJOR = 3 -PYTHON_VERSION_MINOR = 9 +PYTHON_VERSION_MINOR = 10 PYTHON_VERSION_PATCH = 7 PYTHON_VERSION = $(PYTHON_VERSION_MAJOR).$(PYTHON_VERSION_MINOR).$(PYTHON_VERSION_PATCH) PYTHON_BASE_PACKAGES = \ - awscli-local==0.15 \ - awscli==1.20.53 \ - black==21.9b0 \ - boto3==1.18.53 \ + awscli-local==0.18 \ + awscli==1.22.64 \ + black==22.1.0 \ + boto3==1.21.9 \ bpython \ configparser \ coverage \ - diagrams==0.20.0 \ + diagrams==0.21.1 \ flake8 \ mypy \ prettytable \ + pyflakes \ pygments \ pylint \ pytest \ pyyaml \ - requests==2.26.0 + requests==2.27.1 python-install: ### Install and configure Python - optional: PYTHON_VERSION if [ $(SYSTEM_DIST) == macos ]; then diff --git a/build/automation/lib/serverless.mk b/build/automation/lib/serverless.mk deleted file mode 100644 index 9d03c21b9..000000000 --- a/build/automation/lib/serverless.mk +++ /dev/null @@ -1,52 +0,0 @@ -SERVERLESS_VERSION = 2.62 -SERVERLESS_DIR := $(or $(SERVERLESS_DIR), deployment) -SERVERLESS_CONFIG_FILE := $(or $(SERVERLESS_CONFIG_FILE), serverless.yml) - -serverless-build: - cd $(SERVERLESS_DIR) - tar -czf $(DOCKER_DIR)/serverless/assets/serverless.tar.gz serverless.yml - cd $(PROJECT_DIR) - make docker-build NAME=serverless - -serverless-deploy: # Deploy AWS resources - mandatory: PROFILE - make serverless-run CMD=deploy - -serverless-deploy-single-function: # Deploy single AWS lambda - mandatory: PROFILE, FUNCTION_NAME - make serverless-run CMD="deploy function -f $(FUNCTION_NAME)" - -serverless-remove: # Delete existing AWS resources - mandatory: PROFILE - make serverless-run CMD=remove - -serverless-info: # See info on deployed environment - mandatory: PROFILE - make serverless-run CMD=info - -serverless-clean: ### Clean serverless directory - optional: SERVERLESS_DIR=[directory where .serverless is, defaults to deployment] - rm -fv $(DOCKER_DIR)/serverless/assets/*.tar.gz - -serverless-run: # Runs serverless commands - mandatory: PROFILE, CMD=[serverless command]; optional: SERVERLESS_DIR=[directory where .serverless is, defaults to deployment],SERVERLESS_CONFIG_FILE=[serverless config file name, defaults to serverless.yml] - make docker-run-serverless \ - IMAGE=$(DOCKER_REGISTRY)/serverless:latest \ - ARGS="-v $(PROJECT_DIR)/$(SERVERLESS_DIR)/$(SERVERLESS_CONFIG_FILE):/deployment/serverless.yml" \ - CMD="serverless $(CMD) --config $(SERVERLESS_CONFIG_FILE) --stage $(ENVIRONMENT)" - -serverless-install-plugin: ### Install serverless plugin - mandatory: NAME=[plugin name]; optional: SERVERLESS_DIR=[directory where .serverless is, defaults to deployment] - make docker-run-serverless \ - IMAGE=$(DOCKER_REGISTRY)/serverless \ - CMD="serverless plugin install -n $(NAME)" - -docker-run-serverless: - make docker-config > /dev/null 2>&1 - container=$$([ -n "$(CONTAINER)" ] && echo $(CONTAINER) || echo $$(echo '$(IMAGE)' | md5sum | cut -c1-7)-$(BUILD_COMMIT_HASH)-$(BUILD_ID)-$$(date --date=$$(date -u +"%Y-%m-%dT%H:%M:%S%z") -u +"%Y%m%d%H%M%S" 2> /dev/null)-$$(make secret-random LENGTH=8)) - docker run --interactive $(_TTY) --rm \ - --name $$container \ - --env-file <(make _list-variables PATTERN="^(AWS|TX|TEXAS|NHSD|TERRAFORM|SLACK)") \ - --env-file <(make _list-variables PATTERN="^(DB|DATABASE|SMTP|APP|APPLICATION|UI|API|SERVER|HOST|URL)") \ - --env-file <(make _list-variables PATTERN="^(PROFILE|ENVIRONMENT|BUILD|PROGRAMME|ORG|SERVICE|PROJECT|VERSION)") \ - --env-file <(make _docker-get-variables-from-file VARS_FILE=$(VAR_DIR)/project.mk) \ - --env-file <(make _docker-get-variables-from-file VARS_FILE=$(VAR_DIR)/profile/$(PROFILE).mk) \ - --env-file <(make _docker-get-variables-from-file VARS_FILE=$(VARS_FILE)) \ - -e DOCKER_REGISTRY=$(DOCKER_REGISTRY) \ - --network $(DOCKER_NETWORK) \ - $(ARGS) \ - $(IMAGE) \ - $(CMD) diff --git a/build/automation/lib/slack/README.md b/build/automation/lib/slack/README.md index 71c49487a..177b0cad4 100644 --- a/build/automation/lib/slack/README.md +++ b/build/automation/lib/slack/README.md @@ -10,10 +10,11 @@ This is a list of default Slack channels to support development and service main - `[project-name/project-id]-release-notes` for automatically issuing release notes from git history and commit messages - `[team-name]-dev` for day to day development discussions, e.g. pull request announcements and related technical conversations - `[team-name]-swarming` for mob programming and swarming sessions +- `[team-name]-general` for a general team discussion where -- `profile` is provides a set of environment configuration variables and its usage is described [here](https://github.com/nhsd-exeter/make-devops/blob/master/build/automation/lib/project/template/build/automation/var/profile/README.md). +- `profile` is provides a set of environment configuration variables and its usage is described [here](https://github.com/nhsd-exeter/make-devops/blob/main/build/automation/lib/project/template/build/automation/var/profile/README.md). - `project-name` should match the `$(PROJECT_NAME)` make variable defined in the `build/automation/var/project.mk` file, e.g. `integration` or `service-fuzzy-search-api` - `project-id` should be used if more hierarchical naming convention needs to be in place, it is defined as `PROJECT_ID = $(PROJECT_GROUP_SHORT)-$(PROJECT_NAME_SHORT)` in the `build/automation/var/project.mk` file, e.g. `uec-dos-int`, `uec-dos-api-sfsa` - `team-name` should match the the name of the team currently responsible for the project, e.g. `service-finder` or `dos-integration` diff --git a/build/automation/lib/slack/codebuild-pipeline-info.json b/build/automation/lib/slack/codebuild-pipeline-info.json new file mode 100644 index 000000000..3626eaa69 --- /dev/null +++ b/build/automation/lib/slack/codebuild-pipeline-info.json @@ -0,0 +1,26 @@ +{ + "attachments": [ + { + "color": "#808080", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "PIPELINE_NAME_TO_REPLACE" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Status: *BUILD_STATUS_TO_REPLACE* :woohoo: | Project: *${PROJECT_GROUP_SHORT_TO_REPLACE}-${PROJECT_NAME_SHORT_TO_REPLACE}* | Environment: *ENVIRONMENT_TO_REPLACE* | Profile: *PROFILE_TO_REPLACE* +Build: +Commit: *BUILD_COMMIT_HASH_TO_REPLACE* on *BUILD_BRANCH_TO_REPLACE* by +*Message from AWS*: SLACK_MESSAGE_TO_REPLACE" + } + } + ] + } + ] +} diff --git a/build/automation/lib/slack/codebuild-pipeline-pr-merge-failure.json b/build/automation/lib/slack/codebuild-pipeline-pr-merge-failure.json new file mode 100644 index 000000000..2aca1c65c --- /dev/null +++ b/build/automation/lib/slack/codebuild-pipeline-pr-merge-failure.json @@ -0,0 +1,26 @@ +{ + "attachments": [ + { + "color": "#dc3d2a", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "PIPELINE_NAME_TO_REPLACE" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Status: *BUILD_STATUS_TO_REPLACE* :fire: | Project: *${PROJECT_GROUP_SHORT_TO_REPLACE}-${PROJECT_NAME_SHORT_TO_REPLACE}* | Environment: *ENVIRONMENT_TO_REPLACE* | Profile: *PROFILE_TO_REPLACE* +Build: +PR: *PR_TO_REPLACE* +*Message from AWS*: SLACK_MESSAGE_TO_REPLACE" + } + } + ] + } + ] +} diff --git a/build/automation/lib/slack/codebuild-pipeline-pr-merge-success.json b/build/automation/lib/slack/codebuild-pipeline-pr-merge-success.json new file mode 100644 index 000000000..bcb40a51a --- /dev/null +++ b/build/automation/lib/slack/codebuild-pipeline-pr-merge-success.json @@ -0,0 +1,26 @@ +{ + "attachments": [ + { + "color": "#0def42", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "PIPELINE_NAME_TO_REPLACE" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Status: *BUILD_STATUS_TO_REPLACE* :woohoo: | Project: *${PROJECT_GROUP_SHORT_TO_REPLACE}-${PROJECT_NAME_SHORT_TO_REPLACE}* | Environment: *ENVIRONMENT_TO_REPLACE* | Profile: *PROFILE_TO_REPLACE* +Build: +PR: *PR_TO_REPLACE* +*Message from AWS*: SLACK_MESSAGE_TO_REPLACE" + } + } + ] + } + ] +} diff --git a/build/automation/lib/sonar.mk b/build/automation/lib/sonar.mk index 67218844d..d20272dea 100644 --- a/build/automation/lib/sonar.mk +++ b/build/automation/lib/sonar.mk @@ -1,4 +1,4 @@ -SONAR_SCANNER_CLI_VERSION = 4.6 +SONAR_SCANNER_CLI_VERSION = 4.7 sonar-scanner-cli: ### Run Sonar scanner CLI- mandatory: SONAR_HOST_TOKEN; optional: SONAR_HOST_URL=[defaults to 'https://sonarcloud.io'],SONAR_EXCLUSIONS=[e.g. '**/*.java'],SONAR_ARGS make docker-run-sonar-scanner-cli CMD=" \ diff --git a/build/automation/lib/terraform.mk b/build/automation/lib/terraform.mk index 97b227fef..491b0ae5d 100644 --- a/build/automation/lib/terraform.mk +++ b/build/automation/lib/terraform.mk @@ -4,7 +4,7 @@ TERRAFORM_STATE_STORE = $(or $(TEXAS_TERRAFORM_STATE_STORE), state-store-$(AWS_A TERRAFORM_STATE_LOCK = $(or $(TEXAS_TERRAFORM_STATE_LOCK), state-lock-$(AWS_ACCOUNT_NAME)) TERRAFORM_STATE_KEY = $(PROJECT_GROUP_SHORT)-$(PROJECT_NAME_SHORT)/$(ENVIRONMENT) TERRAFORM_STATE_KEY_SHARED = texas -TERRAFORM_VERSION = $(or $(TEXAS_TERRAFORM_VERSION), 1.1.7) +TERRAFORM_VERSION = $(or $(TEXAS_TERRAFORM_VERSION), 1.2.1) # ============================================================================== @@ -149,10 +149,14 @@ _terraform-stacks: ### Set up infrastructure for a given list of stacks - mandat eval "$$(make terraform-export-variables)" # run stacks for stack in $$(echo $(or $(STACK), $(or $(STACKS), $(INFRASTRUCTURE_STACKS))) | tr "," "\n"); do - make _terraform-stack STACK="$$stack" CMD="$(CMD)" + make _terraform-stack STACK="$$stack" CMD="$(CMD)" AWS_PROFILE="" done _terraform-stack: ### Set up infrastructure for a single stack - mandatory: STACK=[name],CMD=[Terraform command]; optional: TERRAFORM_REINIT=false,PROFILE=[name] + if [ -d $(TERRAFORM_DIR)/common ]; then + make _terraform-remove-common STACK=$(STACK) + make _terraform-copy-common STACK=$(STACK) + fi if [ -f $(TERRAFORM_DIR)/$(STACK)/terraform.tf ]; then if [ "$(TERRAFORM_USE_STATE_STORE)" == false ]; then sed -i 's/ backend "s3"/ #backend "s3"/g' $(TERRAFORM_DIR)/$(STACK)/terraform.tf @@ -164,10 +168,17 @@ _terraform-stack: ### Set up infrastructure for a single stack - mandatory: STAC make _terraform-reinitialise DIR="$(TERRAFORM_DIR)" STACK="$(STACK)" fi make docker-run-terraform DIR="$(TERRAFORM_DIR)/$(STACK)" CMD="$(CMD)" + if [ -d $(TERRAFORM_DIR)/common ]; then + make _terraform-remove-common STACK=$(STACK) + fi -_terraform-reinitialise: ### Reinitialise infrastructure state - mandatory: STACK=[name]; optional: TERRAFORM_DO_NOT_REMOVE_STATE_FILE=true,PROFILE=[name] +_terraform-reinitialise: ### Reinitialise infrastructure state - mandatory: STACK=[name]; optional: TERRAFORM_DO_NOT_REMOVE_STATE_FILE=true,PROFILE=[name],HIDE_OUTPUT=false [ "$(TERRAFORM_DO_NOT_REMOVE_STATE_FILE)" != true ] && rm -rf $(DIR)/$(STACK)/*terraform.tfstate* - make _terraform-initialise STACK="$(STACK)" + if [[ "$(HIDE_INIT)" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$$ ]]; then + make _terraform-initialise STACK="$(STACK)" > /dev/null 2>&1 + else + make _terraform-initialise STACK="$(STACK)" + fi _terraform-initialise: ### Initialise infrastructure state - mandatory: STACK=[name]; optional: TERRAFORM_USE_STATE_STORE=false,PROFILE=[name] if [[ "$(TERRAFORM_USE_STATE_STORE)" =~ ^(false|no|n|off|0|FALSE|NO|N|OFF)$$ ]]; then @@ -200,6 +211,14 @@ _terraform-delete-state-lock: ### Delete Terraform state lock - mandatory: STACK # ============================================================================== +_terraform-copy-common: # Copies all common terraform files to the desired stack - Mandatory: STACK - name of stack to copy common terraform file into + cp $(TERRAFORM_DIR)/common/common-*.tf $(TERRAFORM_DIR)/$(STACK) + +_terraform-remove-common: # Removes all common terraform files from the desired stack - Mandatory: STACK - name of stack to clean up common terraform file from + rm -f $(TERRAFORM_DIR)/$(STACK)/common-*.tf + +# ============================================================================== + terraform-check-module-versions: ### Check Terraform module versions alignment # acm terraform-aws-modules/acm/aws name="terraform acm terraform-aws-modules/acm/aws" @@ -277,4 +296,6 @@ terraform-check-module-versions: ### Check Terraform module versions alignment terraform-export-variables-from-secret \ terraform-export-variables-from-shell \ terraform-output \ - terraform-show + terraform-show \ + terraform-copy-common \ + terraform-remove-common diff --git a/build/automation/lib/terraform/template/modules/s3/main.tf b/build/automation/lib/terraform/template/modules/s3/main.tf index 616b94cb9..5ce2e3973 100644 --- a/build/automation/lib/terraform/template/modules/s3/main.tf +++ b/build/automation/lib/terraform/template/modules/s3/main.tf @@ -13,6 +13,9 @@ module "s3" { block_public_policy = true ignore_public_acls = true restrict_public_buckets = true + logging = { + target_bucket = "target-bucket" + } server_side_encryption_configuration = { rule = { apply_server_side_encryption_by_default = { diff --git a/build/automation/lib/test.mk b/build/automation/lib/test.mk index 339c1883d..8efd94b8d 100644 --- a/build/automation/lib/test.mk +++ b/build/automation/lib/test.mk @@ -1,8 +1,8 @@ TEST_VNC_HOST = localhost TEST_VNC_PORT = 5900 -WIREMOCK_VERSION = 2.28.0 -POSTMAN_NEWMAN_VERSION = 5.2.3 +WIREMOCK_VERSION = 2.32.0 +POSTMAN_NEWMAN_VERSION = 5.3 # ============================================================================== diff --git a/build/automation/test/docker.test.mk b/build/automation/test/docker.test.mk index 262927558..3f79f9104 100644 --- a/build/automation/test/docker.test.mk +++ b/build/automation/test/docker.test.mk @@ -37,7 +37,7 @@ test-docker: test-docker-run-python-multiple-cmd-pip-install \ test-docker-run-terraform \ test-docker-run-terraform-tfsec \ - test-docker-run-terraform-checkov \ + test-docker-run-checkov \ test-docker-run-terraform-compliance \ test-docker-run-config-lint \ test-docker-run-tools-single-cmd \ @@ -348,12 +348,12 @@ test-docker-run-terraform-tfsec: # assert mk_test "1 -eq $$output" -test-docker-run-terraform-checkov: +test-docker-run-checkov: # arrange make docker-config # act output=$$( - make -s docker-run-terraform-checkov \ + make -s docker-run-checkov \ DIR="build/automation/lib/terraform/template/modules/s3" \ | grep -Eo "By bridgecrew" | wc -l) # assert diff --git a/build/automation/test/git.test.mk b/build/automation/test/git.test.mk index d2d7fd1eb..3278493ee 100644 --- a/build/automation/test/git.test.mk +++ b/build/automation/test/git.test.mk @@ -61,7 +61,7 @@ test-git-config: test-git-check-if-branch-name-is-correct: # assert - mk_test "01" "$$(make git-check-if-branch-name-is-correct BUILD_BRANCH=master) = true" + mk_test "01" "$$(make git-check-if-branch-name-is-correct BUILD_BRANCH=main) = true" mk_test "02" "$$(make git-check-if-branch-name-is-correct BUILD_BRANCH=task/ABC-123_Descriptive_branch_name) = true" mk_test "03" "$$(make git-check-if-branch-name-is-correct BUILD_BRANCH=task/Update_automation_scripts) = true" mk_test "04" "$$(make git-check-if-branch-name-is-correct BUILD_BRANCH=task/Update_dependencies) = true" @@ -89,23 +89,23 @@ test-git-check-if-commit-msg-is-correct: # arrange echo -e "ABC-123 This commit message is ok\n\nHere is more information" > $(TEST_GIT_COMMIT_MESSAGE_FILE) # assert - mk_test "01" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="ABC-123 This is a test") = true" - mk_test "02" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Update automation scripts") = true" - mk_test "03" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Update automation scripts to 20210609082320-b083a16") = true" - mk_test "04" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Update dependencies") = true" - mk_test "05" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Update dependencies!") = false" - mk_test "06" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Update dependencies this time?") = false" - mk_test "07" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Update versions") = true" - mk_test "08" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Update documentation") = true" - mk_test "09" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Update this thing") = true" - mk_test "10" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Refactor this thing") = true" - mk_test "11" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Automate this thing") = true" - mk_test "12" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Test this thing") = true" - mk_test "13" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Fix this thing") = true" - mk_test "14" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Release to live") = true" - mk_test "15" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Migrate this to alternative") = true" - mk_test "16" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="Put not conventional commit message") = false" - mk_test "17" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=master BUILD_COMMIT_MESSAGE="start with a lowercase letter") = false" + mk_test "01" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="ABC-123 This is a test") = true" + mk_test "02" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Update automation scripts") = true" + mk_test "03" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Update automation scripts to 20210609082320-b083a16") = true" + mk_test "04" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Update dependencies") = true" + mk_test "05" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Update dependencies!") = false" + mk_test "06" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Update dependencies this time?") = false" + mk_test "07" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Update versions") = true" + mk_test "08" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Update documentation") = true" + mk_test "09" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Update this thing") = true" + mk_test "10" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Refactor this thing") = true" + mk_test "11" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Automate this thing") = true" + mk_test "12" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Test this thing") = true" + mk_test "13" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Fix this thing") = true" + mk_test "14" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Release to live") = true" + mk_test "15" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Migrate this to alternative") = true" + mk_test "16" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="Put not conventional commit message") = false" + mk_test "17" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=main BUILD_COMMIT_MESSAGE="start with a lowercase letter") = false" mk_test "18" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=task/ABC-123_Descriptive_branch_name BUILD_COMMIT_MESSAGE="ABC-123 Too short") = false" mk_test "19" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=task/ABC-123_Descriptive_branch_name BUILD_COMMIT_MESSAGE="abc-123 Not so ok commit message") = false" mk_test "20" "$$(make git-check-if-commit-msg-is-correct BUILD_BRANCH=task/ABC-123_Descriptive_branch_name BUILD_COMMIT_MESSAGE="ABC-123 another not so ok commit message") = false" @@ -122,8 +122,8 @@ test-git-check-if-commit-msg-is-correct: test-git-check-if-pull-request-title-is-correct: # assert - mk_test "01" "$$(make git-check-if-pull-request-title-is-correct BUILD_BRANCH=master PULL_REQUEST_TITLE="ABC-123 This is a passing test") = true" - mk_test "02" "$$(make git-check-if-pull-request-title-is-correct BUILD_BRANCH=master PULL_REQUEST_TITLE="ABC-123 This is a failing tast due to the summary being too long being too long being too long being too long being too long") = false" + mk_test "01" "$$(make git-check-if-pull-request-title-is-correct BUILD_BRANCH=main PULL_REQUEST_TITLE="ABC-123 This is a passing test") = true" + mk_test "02" "$$(make git-check-if-pull-request-title-is-correct BUILD_BRANCH=main PULL_REQUEST_TITLE="ABC-123 This is a failing tast due to the summary being too long being too long being too long being too long being too long") = false" test-git-secrets-load: mk_test_skip @@ -149,7 +149,7 @@ test-git-secrets-scan-repo-files: test-git-check-if-commit-changed-directory: # act output=$$(make git-check-if-commit-changed-directory DIR=build/automation/tmp) - # assert + # assert mk_test "false == $$output" test-git-commit-get-hash: diff --git a/build/automation/test/jenkins.test.mk b/build/automation/test/jenkins.test.mk index 3460d9722..76af27f22 100644 --- a/build/automation/test/jenkins.test.mk +++ b/build/automation/test/jenkins.test.mk @@ -28,20 +28,22 @@ test-jenkins-create-pipeline-from-template: mk_test_skip test-jenkins-upload-workspace-archived: - # act - make jenkins-upload-workspace ARCHIVE=true - # assert - make aws-s3-download \ - URI=$(JENKINS_WORKSPACE_BUCKET_URI)/workspace-$(PROJECT_NAME_SHORT)-$(BUILD_TIMESTAMP)-$$(printf "%04d\n" $(BUILD_ID))-$(BUILD_COMMIT_HASH).tar.gz \ - FILE=$(TMP_DIR_REL)/workspace-$(PROJECT_NAME_SHORT)-$(BUILD_TIMESTAMP)-$$(printf "%04d\n" $(BUILD_ID))-$(BUILD_COMMIT_HASH).tar.gz.download - mk_test "-f $(TMP_DIR)/workspace-$(PROJECT_NAME_SHORT)-$(BUILD_TIMESTAMP)-$$(printf "%04d\n" $(BUILD_ID))-$(BUILD_COMMIT_HASH).tar.gz.download" + mk_test_skip + # # act + # make jenkins-upload-workspace ARCHIVE=true + # # assert + # make aws-s3-download \ + # URI=$(JENKINS_WORKSPACE_BUCKET_URI)/workspace-$(PROJECT_NAME_SHORT)-$(BUILD_TIMESTAMP)-$$(printf "%04d\n" $(BUILD_ID))-$(BUILD_COMMIT_HASH).tar.gz \ + # FILE=$(TMP_DIR_REL)/workspace-$(PROJECT_NAME_SHORT)-$(BUILD_TIMESTAMP)-$$(printf "%04d\n" $(BUILD_ID))-$(BUILD_COMMIT_HASH).tar.gz.download + # mk_test "-f $(TMP_DIR)/workspace-$(PROJECT_NAME_SHORT)-$(BUILD_TIMESTAMP)-$$(printf "%04d\n" $(BUILD_ID))-$(BUILD_COMMIT_HASH).tar.gz.download" test-jenkins-upload-workspace-exploded: - # act - make jenkins-upload-workspace - # assert - id=$$(printf "%04d\n" $(BUILD_ID)) - make aws-s3-download \ - URI=$(JENKINS_WORKSPACE_BUCKET_URI)/$(BUILD_TIMESTAMP)-$${id}-$(BUILD_COMMIT_HASH)/README.md \ - FILE=$(TMP_DIR_REL)/workspace-README.md.download - mk_test "-f $(TMP_DIR)/workspace-README.md.download" + mk_test_skip + # # act + # make jenkins-upload-workspace + # # assert + # id=$$(printf "%04d\n" $(BUILD_ID)) + # make aws-s3-download \ + # URI=$(JENKINS_WORKSPACE_BUCKET_URI)/$(BUILD_TIMESTAMP)-$${id}-$(BUILD_COMMIT_HASH)/README.md \ + # FILE=$(TMP_DIR_REL)/workspace-README.md.download + # mk_test "-f $(TMP_DIR)/workspace-README.md.download" diff --git a/build/automation/test/terraform.test.mk b/build/automation/test/terraform.test.mk index b57d3a981..3fd8521fb 100644 --- a/build/automation/test/terraform.test.mk +++ b/build/automation/test/terraform.test.mk @@ -43,7 +43,7 @@ test-terraform-teardown: # ============================================================================== test-terraform-export-variables: - # arrange + # arrange export AWS_ACCESS_KEY_ID_test=value export AWS_SECRET_ACCESS_KEY_test=value export AWS_SESSION_TOKEN_test=value @@ -54,7 +54,7 @@ test-terraform-export-variables: mk_test "3 = $$count" test-terraform-export-variables-from-secret: - # arrange + # arrange export _TEST_DB_USERNAME=admin export _TEST_DB_PASSWORD=secret make secret-create NAME=service/deployment-$(@) VARS=_TEST_DB_USERNAME,_TEST_DB_PASSWORD @@ -65,7 +65,7 @@ test-terraform-export-variables-from-secret: mk_test "true = $$(echo "$$export" | grep -q "export TF_VAR__test_db_username='admin'" && echo $$export | grep -q "export TF_VAR__test_db_password='secret'" && echo true)" test-terraform-export-variables-from-shell-vars: - # arrange + # arrange export _TEST_DB_USERNAME=admin export _TEST_DB_PASSWORD=secret # act @@ -75,7 +75,7 @@ test-terraform-export-variables-from-shell-vars: mk_test "2 = $$count" test-terraform-export-variables-from-shell-pattern: - # arrange + # arrange export _TEST_DB_USERNAME=admin export _TEST_DB_PASSWORD=secret # act @@ -85,7 +85,7 @@ test-terraform-export-variables-from-shell-pattern: mk_test "2 = $$count" test-terraform-export-variables-from-shell-pattern-and-vars: - # arrange + # arrange export _TEST_DB_USERNAME=admin export _TEST_DB_PASSWORD=secret export _TEST_UI_USERNAME=user @@ -100,7 +100,7 @@ test-terraform-export-variables-from-shell-pattern-and-vars: mk_test "6 = $$count" test-terraform-export-variables-from-json: - # arrange + # arrange json='{"DB_USERNAME":"admin","DB_PASSWORD":"secret"}' # act export=$$(make terraform-export-variables-from-json JSON="$$json") @@ -108,7 +108,7 @@ test-terraform-export-variables-from-json: mk_test "true = $$(echo "$$export" | grep -q "TF_VAR_db_username='admin'" && echo "$$export" | grep -q "export TF_VAR_db_password='secret'" && echo true)" test-terraform-fmt: - # arrange + # arrange make TEST_TERRAFORM_FORMATTING_INPUT # act make terraform-fmt DIR=$$(echo $(TMP_DIR) | sed "s;$(PROJECT_DIR);;g") @@ -123,7 +123,7 @@ test-terraform-plan-before-apply: # act output=$$(make terraform-plan STACKS=service) # assert - str="1 to add, 0 to change, 0 to destroy\." + str="2 to add, 0 to change, 0 to destroy\." count=$$(echo "$$output" | grep "$$str" | wc -l) mk_test "1 = $$count" @@ -131,7 +131,7 @@ test-terraform-apply: # act output=$$(make terraform-apply-auto-approve STACKS=service) # assert - str="Apply complete! Resources: 1 added, 0 changed, 0 destroyed\." + str="Apply complete! Resources: 2 added, 0 changed, 0 destroyed\." count=$$(echo "$$output" | grep "$$str" | wc -l) mk_test "1 = $$count" @@ -153,7 +153,7 @@ test-terraform-destroy: # act output=$$(make terraform-destroy-auto-approve STACKS=service) # assert - str="Destroy complete! Resources: 1 destroyed\." + str="Destroy complete! Resources: 2 destroyed\." count=$$(echo "$$output" | grep "$$str" | wc -l) mk_test "1 = $$count" diff --git a/build/automation/test/terraform/infrastructure/stacks/service/main.tf b/build/automation/test/terraform/infrastructure/stacks/service/main.tf index e50bdc90b..9b099237e 100644 --- a/build/automation/test/terraform/infrastructure/stacks/service/main.tf +++ b/build/automation/test/terraform/infrastructure/stacks/service/main.tf @@ -1,4 +1,12 @@ resource "aws_s3_bucket" "b" { bucket = "test-bucket" - acl = "public-read" + tags = { + Name = "bucket" + Environment = "dev" + } +} + +resource "aws_s3_bucket_acl" "acl" { + bucket = aws_s3_bucket.b.id + acl = "private" } diff --git a/build/automation/test/test.sh b/build/automation/test/test.sh index 242c1e8bc..db990b6e4 100644 --- a/build/automation/test/test.sh +++ b/build/automation/test/test.sh @@ -90,12 +90,12 @@ function mk_test_print() { ( set +x if test -t 1 && [ -n "$TERM" ] && [ "$TERM" != "dumb" ]; then - [ -n "$3" ] && tput setab $3 - [ -n "$2" ] && tput setaf $2 + [ -n "$3" ] && tput setab $3 ||: + [ -n "$2" ] && tput setaf $2 ||: fi printf "$1" if test -t 1 && [ -n "$TERM" ] && [ "$TERM" != "dumb" ]; then - tput sgr 0 + tput sgr 0 ||: fi ) } diff --git a/build/automation/var/README.md b/build/automation/var/README.md index c54e4a3af..d6f67439a 100644 --- a/build/automation/var/README.md +++ b/build/automation/var/README.md @@ -7,9 +7,8 @@ Profile is a set of configuration options specific to an environment expressed a ## Defaults * `local` - local development profile (Not attached to an AWS Account) -* `task` - a specific environment for branches. This should be default for creating non-prod infrastructure (AWS Non-prod Account) -* `dev` - shared development profile that supports automation on every commit to the remote master branch in the CI pipeline or it is used for a cleanup (AWS Non-prod Account) -* `test` - test profile that runs nightly in the CI pipeline (AWS Non-prod Account) +* `dev` - shared development profile that supports automation on every commit to the remote main branch in the CI pipeline or it is used for a clean up jobs (AWS Non-prod Account) * `demo` - production, UX (AWS Prod Account) * `live` - production, service (AWS Prod Account) * `tools,tools-nonprod,tools-prod` - CI/CD execution environment +* `perf` - a profile for performance testing which uses the DoS Perf DB (AWS Non-prod Account) diff --git a/build/automation/var/platform-texas/platform-texas-v2.mk b/build/automation/var/platform-texas/platform-texas-v2.mk index af9e6e714..d7dc9959e 100644 --- a/build/automation/var/platform-texas/platform-texas-v2.mk +++ b/build/automation/var/platform-texas/platform-texas-v2.mk @@ -3,7 +3,6 @@ TEXAS_VERSION = v2 AWS_DEFAULT_REGION = eu-west-2 AWS_ALTERNATIVE_REGION = eu-west-1 AWS_REGION = $(AWS_DEFAULT_REGION) -AWS_PROFILE = $(or $(TEXAS_PROFILE), $(PROJECT_ID)-$(AWS_ACCOUNT_NAME)) AWS_ROLE_ADMIN = NHSDServiceTeamAdminRole AWS_ROLE_READONLY = NHSDServiceTeamReadOnlyRole diff --git a/build/automation/var/platform-texas/v1/account-live-k8s-nonprod.mk b/build/automation/var/platform-texas/v1/account-live-k8s-nonprod.mk index 435112d2c..98d57405b 100644 --- a/build/automation/var/platform-texas/v1/account-live-k8s-nonprod.mk +++ b/build/automation/var/platform-texas/v1/account-live-k8s-nonprod.mk @@ -6,6 +6,16 @@ TEXAS_WAF_ACL_ID = dfae6ec3-aa05-428f-a022-5fd85f646009 TF_VAR_terraform_platform_state_store = nhsd-texasplatform-terraform-state-store-live-lk8s-$(AWS_ACCOUNT_NAME) +TF_VAR_developer_role = UECDoSINTServiceDeveloper + + +SYSTEM_EMAIL_KEY := DI_SYSTEM_MAILBOX_ADDRESS +TEAM_EMAIL_KEY := DI_TEAM_MAILBOX_ADDRESS + +TF_VAR_texas_hosted_zone = $(TEXAS_HOSTED_ZONE) +TF_VAR_ip_address_secret := uec-dos-int-dev-ip-addresses-allowlist +DEPLOYMENT_SECRETS = $(PROJECT_ID)-dev/deployment + # ============================================================================== include $(VAR_DIR)/platform-texas/platform-texas-v1.mk diff --git a/build/automation/var/platform-texas/v1/account-live-k8s-prod.mk b/build/automation/var/platform-texas/v1/account-live-k8s-prod.mk index 59ec58f52..de03179ce 100644 --- a/build/automation/var/platform-texas/v1/account-live-k8s-prod.mk +++ b/build/automation/var/platform-texas/v1/account-live-k8s-prod.mk @@ -6,6 +6,13 @@ TEXAS_WAF_ACL_ID = ff530a4e-689d-4d90-b3ab-ac2160b5863d TF_VAR_terraform_platform_state_store = nhsd-texasplatform-terraform-state-store-lk8s-$(AWS_ACCOUNT_NAME) +TF_VAR_developer_role = UECDoSINTSupportServiceDeveloper + +SYSTEM_EMAIL_KEY := DI_SYSTEM_MAILBOX_ADDRESS +TEAM_EMAIL_KEY := DI_TEAM_MAILBOX_ADDRESS + +TF_VAR_texas_hosted_zone = $(TEXAS_HOSTED_ZONE) + # ============================================================================== include $(VAR_DIR)/platform-texas/platform-texas-v1.mk diff --git a/build/automation/var/profile/demo.mk b/build/automation/var/profile/demo.mk index b34a6a3a4..a2526e085 100644 --- a/build/automation/var/profile/demo.mk +++ b/build/automation/var/profile/demo.mk @@ -1,30 +1,61 @@ --include $(VAR_DIR)/profile/prod.mk +-include $(VAR_DIR)/platform-texas/v1/account-live-k8s-prod.mk # ============================================================================== # Service variables +DEPLOYMENT_SECRETS = $(PROJECT_ID)-demo/deployment # Move to project.mk when nonprod profiles are merged -LOG_LEVEL:= INFO +LOG_LEVEL := INFO -DOS_API_GATEWAY_SECRETS = core-dos-uet/deployment -DOS_API_GATEWAY_USERNAME_KEY := DOS_UET_API_GATEWAY_USER -DOS_API_GATEWAY_PASSWORD_KEY := DOS_UET_API_GATEWAY_PASSWORD -DOS_API_GATEWAY_URL := https://api-gateway-usertest.directoryofservices.nhs.uk/api/change-request +# DB Name +DB_CLUSTER_NAME := uec-core-dos-put-cluster-14 +DB_WRITER_NAME := uec-core-dos-put-cluster-14-one +DB_READER_NAME := uec-core-dos-put-cluster-14-two -DB_SERVER_NAME := uec-core-dos-put-db-12-replica-di +# DB Route 53s +DB_WRITER_ROUTE_53 := uec-core-dos-put-dos-primary-rw.dos-datastore-put +DB_READER_ROUTE_53 := uec-core-dos-put-di-ro.dos-datastore-put + +# DB Connection Variables DB_PORT := 5432 -DB_NAME := pathwaysdos_ut +DB_NAME := pathwaysdos_uat3 DB_SCHEMA := pathwaysdos -DB_SECRET_NAME := core-dos-uet-database-upgrade/deployment -DB_SECRET_KEY := DB_DI_READONLY_PASSWORD + +# DB Security Groups +DB_SG_NAME := uec-core-dos-put-datastore-di-sg + +# DB Secrets +DB_WRITER_SECRET_NAME := core-dos-uet-database-upgrade/deployment +DB_WRITER_SECRET_KEY := DB_DI_READWRITE_PASSWORD DB_USER_NAME_SECRET_NAME = uec-dos-int-demo/deployment -DB_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME +DB_READER_SECRET_NAME := core-dos-uet-database-upgrade/deployment +DB_READER_SECRET_KEY := DB_DI_READONLY_PASSWORD +DB_READ_ONLY_USER_NAME_SECRET_NAME = uec-dos-int-demo/deployment +DB_READ_ONLY_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME +# IP Address Secrets TF_VAR_ip_address_secret := uec-dos-int-demo-ip-addresses-allowlist -SLACK_WEBHOOK_SECRET_NAME = uec-dos-int-$(PROFILE)/deployment -SLACK_WEBHOOK_SECRET_KEY = SLACK_WEBHOOK + +# Slack Secrets SLACK_ALERT_CHANNEL := dos-integration-dev-status +# WAF +WAF_ENABLED := true +DDB_DELETE_PROTECTION :=false + +# ============================================================================== +# Performance variables + +SERVICE_MATCHER_MAX_CONCURRENCY := 28 +SERVICE_SYNC_MAX_CONCURRENCY := 47 + +# ============================================================================== +# DoS DB Handler + +DOS_DEPLOYMENT_SECRETS := core-dos-uet/deployment +DOS_DEPLOYMENT_SECRETS_PASSWORD_KEY := DB_DI_READWRITE_PASSWORD +DOS_DB_HANDLER_DB_READ_AND_WRITE_USER_NAME = $(DB_READ_AND_WRITE_USER_NAME) + # ============================================================================== -# Organisation Types Feature Flags +# Quality Checker Variables -ACCEPTED_ORG_TYPES := PHA +ODSCODE_STARTING_CHARACTER := F diff --git a/build/automation/var/profile/dev.mk b/build/automation/var/profile/dev.mk index 25f575988..d180e7c73 100644 --- a/build/automation/var/profile/dev.mk +++ b/build/automation/var/profile/dev.mk @@ -1,26 +1,60 @@ --include $(VAR_DIR)/profile/nonprd.mk +-include $(VAR_DIR)/platform-texas/v1/account-live-k8s-nonprod.mk # ============================================================================== # Service variables -LOG_LEVEL:= DEBUG +LOG_LEVEL := DEBUG -DOS_API_GATEWAY_SECRETS = $(TF_VAR_dos_api_gateway_secret) -DOS_API_GATEWAY_USERNAME_KEY := DOS_API_GATEWAY_USERNAME -DOS_API_GATEWAY_PASSWORD_KEY := DOS_API_GATEWAY_PASSWORD +# DB Name +DB_CLUSTER_NAME := uec-core-dos-regression-cluster-14 +DB_WRITER_NAME := uec-core-dos-regression-cluster-14-one +DB_READER_NAME := uec-core-dos-regression-cluster-14-two -DOS_API_GATEWAY_URL := $(or $(DOS_API_GATEWAY_MOCK_URL), "//") +# DB Route 53s +DB_WRITER_ROUTE_53 := uec-core-dos-regression-dos-primary-rw.dos-datastore-regression +DB_READER_ROUTE_53 := uec-core-dos-regression-di-ro.dos-datastore-regression -DB_SERVER_NAME := uec-core-dos-regression-db-12-replica-di +# DB Connection Variables DB_PORT := 5432 -DB_NAME := pathwaysdos_regression +DB_NAME := pathwaysdos_regressiondi DB_SCHEMA := pathwaysdos -DB_SECRET_NAME := core-dos-dev/deployment -DB_SECRET_KEY := DB_DI_READONLY_PASSWORD + +# DB Security Groups +DB_SG_NAME := uec-core-dos-regression-datastore-di-sg + +# DB Secrets +DB_WRITER_SECRET_NAME := core-dos-dev/deployment +DB_WRITER_SECRET_KEY := DB_DI_READWRITE_PASSWORD DB_USER_NAME_SECRET_NAME = uec-dos-int-dev/deployment -DB_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME +DB_READER_SECRET_NAME := core-dos-dev/deployment +DB_READER_SECRET_KEY := DB_DI_READONLY_PASSWORD +DB_READ_ONLY_USER_NAME_SECRET_NAME = uec-dos-int-dev/deployment +DB_READ_ONLY_USER_NAME_SECRET_KEY = DOS_DB_REPLICA_DI_USERNAME +# IP Address Secrets TF_VAR_ip_address_secret := uec-dos-int-dev-ip-addresses-allowlist -SLACK_WEBHOOK_SECRET_NAME = uec-dos-int-dev/deployment -SLACK_WEBHOOK_SECRET_KEY = SLACK_WEBHOOK + +# Slack Secrets SLACK_ALERT_CHANNEL := dos-integration-dev-status + +# WAF +WAF_ENABLED := false +DDB_DELETE_PROTECTION :=false + +# ============================================================================== +# Performance variables + +SERVICE_MATCHER_MAX_CONCURRENCY := 20 +SERVICE_SYNC_MAX_CONCURRENCY := 20 + +# ============================================================================== +# DoS DB Handler + +DOS_DEPLOYMENT_SECRETS := core-dos-dev/deployment +DOS_DEPLOYMENT_SECRETS_PASSWORD_KEY := DB_RELEASE_USER_PASSWORD +DOS_DB_HANDLER_DB_READ_AND_WRITE_USER_NAME := pathwaysdos + +# ============================================================================== +# Quality Checker Variables + +ODSCODE_STARTING_CHARACTER := A diff --git a/build/automation/var/profile/live.mk b/build/automation/var/profile/live.mk index d170b4eda..687f41931 100644 --- a/build/automation/var/profile/live.mk +++ b/build/automation/var/profile/live.mk @@ -1,30 +1,62 @@ --include $(VAR_DIR)/profile/prod.mk +-include $(VAR_DIR)/platform-texas/v1/account-live-k8s-prod.mk # ============================================================================== # Service variables -LOG_LEVEL:= INFO +DEPLOYMENT_SECRETS = $(PROJECT_ID)-live/deployment # Move to project.mk when nonprod profiles are merged -DOS_API_GATEWAY_SECRETS = core-dos/deployment -DOS_API_GATEWAY_USERNAME_KEY := DOS_API_GATEWAY_USER -DOS_API_GATEWAY_PASSWORD_KEY := DOS_API_GATEWAY_PASSWORD -DOS_API_GATEWAY_URL := https://api-gateway.directoryofservices.nhs.uk/api/change-request +LOG_LEVEL := INFO -DB_SERVER_NAME := uec-core-dos-live-db-12-replica-di +# DB Name +DB_CLUSTER_NAME := uec-core-dos-live-cluster-14 +DB_WRITER_NAME := uec-core-dos-live-cluster-14-one +DB_READER_NAME := uec-core-dos-live-cluster-14-two + +# DB Route 53s +DB_WRITER_ROUTE_53 := uec-core-dos-live-dos-primary-rw.dos-datastore-live +DB_READER_ROUTE_53 := uec-core-dos-live-di-ro.dos-datastore-live + +# DB Connection Variables DB_PORT := 5432 DB_NAME := pathwaysdos DB_SCHEMA := pathwaysdos -DB_SECRET_NAME := core-dos/deployment -DB_SECRET_KEY := DB_DI_READONLY_PASSWORD + +# DB Security Groups +DB_SG_NAME := uec-core-dos-live-datastore-di-sg + +# DB Secrets +DB_WRITER_SECRET_NAME := core-dos/deployment +DB_WRITER_SECRET_KEY := DB_DI_READWRITE_PASSWORD DB_USER_NAME_SECRET_NAME = uec-dos-int-live/deployment -DB_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME +DB_READER_SECRET_NAME := core-dos/deployment +DB_READER_SECRET_KEY := DB_DI_READONLY_PASSWORD +DB_READ_ONLY_USER_NAME_SECRET_NAME = uec-dos-int-live/deployment +DB_READ_ONLY_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME +# IP Address Secrets TF_VAR_ip_address_secret := uec-dos-int-live-ip-addresses-allowlist -SLACK_WEBHOOK_SECRET_NAME = uec-dos-int-live/deployment -SLACK_WEBHOOK_SECRET_KEY = SLACK_WEBHOOK + +# Slack Secrets SLACK_ALERT_CHANNEL := dos-integration-live-status +# WAF +WAF_ENABLED := true +DDB_DELETE_PROTECTION :=true + +# ============================================================================== +# Performance variables + +SERVICE_MATCHER_MAX_CONCURRENCY := 28 +SERVICE_SYNC_MAX_CONCURRENCY := 47 + +# ============================================================================== +# DoS DB Handler + +DOS_DEPLOYMENT_SECRETS := null +DOS_DEPLOYMENT_SECRETS_PASSWORD_KEY := null +DOS_DB_HANDLER_DB_READ_AND_WRITE_USER_NAME := null + # ============================================================================== -# Organisation Types Feature Flags +# Quality Checker Variables -ACCEPTED_ORG_TYPES := PHA +ODSCODE_STARTING_CHARACTER := F diff --git a/build/automation/var/profile/local.mk b/build/automation/var/profile/local.mk index 7671c75b7..409412dd5 100644 --- a/build/automation/var/profile/local.mk +++ b/build/automation/var/profile/local.mk @@ -1,25 +1,3 @@ --include $(VAR_DIR)/profile/nonprd.mk # To allow for docker build to work correct +-include $(VAR_DIR)/platform-texas/v1/account-live-k8s-nonprod.mk # ============================================================================== -# Service Variables - -API_GATEWAY_USERNAME := user -API_GATEWAY_PASSWORD := password -CHANGE_REQUEST_ENDPOINT_URL := http://mockserver:1080/api/change-request -CHANGE_REQUEST_ENDPOINT_TIMEOUT := 30 - -DB_SERVER := docker.for.mac.localhost -DB_PORT := 5432 -DB_SCHEMA := public -DB_NAME := postgres -DB_USER_NAME := postgres -DB_PASSWORD := postgres -DB_SCHEMA := public - -# ============================================================================== -# Component Test Variables - -MOCKSERVER_URL := http://mockserver:1080 -EVENT_RECEIVER_FUNCTION_URL := http://docker.for.mac.localhost:9000/2015-03-31/functions/function/invocations -EVENT_PROCESSOR_FUNCTION_URL := http://docker.for.mac.localhost:9001/2015-03-31/functions/function/invocations -EVENT_SENDER_FUNCTION_URL := http://docker.for.mac.localhost:9002/2015-03-31/functions/function/invocations diff --git a/build/automation/var/profile/nonprd.mk b/build/automation/var/profile/nonprd.mk deleted file mode 100644 index e4f72a697..000000000 --- a/build/automation/var/profile/nonprd.mk +++ /dev/null @@ -1,11 +0,0 @@ --include $(VAR_DIR)/platform-texas/v1/account-live-k8s-nonprod.mk - -TF_VAR_texas_hosted_zone = $(TEXAS_HOSTED_ZONE) -DOS_INTEGRATION_URL := $(TF_VAR_dos_integration_sub_domain_name).$(TEXAS_HOSTED_ZONE)/v1/change-event -DOS_API_GATEWAY_MOCK_URL := https://$(TF_VAR_dos_api_gateway_name).$(TEXAS_HOSTED_ZONE)/change-request -TF_VAR_ip_address_secret := uec-dos-int-dev-ip-addresses-allowlist - -# ============================================================================== -# Organisation Types Feature Flags - -ACCEPTED_ORG_TYPES := PHA diff --git a/build/automation/var/profile/p2.mk b/build/automation/var/profile/p2.mk deleted file mode 100644 index b866cd395..000000000 --- a/build/automation/var/profile/p2.mk +++ /dev/null @@ -1,28 +0,0 @@ --include $(VAR_DIR)/profile/nonprd.mk - -# ============================================================================== -# Service variables - -LOG_LEVEL:= DEBUG - -DOS_API_GATEWAY_SECRETS = $(TF_VAR_dos_api_gateway_secret) -DOS_API_GATEWAY_USERNAME_KEY := DOS_API_GATEWAY_USER -DOS_API_GATEWAY_PASSWORD_KEY := DOS_API_GATEWAY_PASSWORD -DOS_API_GATEWAY_REQUEST_TIMEOUT := 30 -DOS_API_GATEWAY_URL := $(or $(DOS_API_GATEWAY_MOCK_URL), "//") - -DB_SERVER_NAME := uec-core-dos-performance-db-12-replica-di -DB_PORT := 5432 -DB_NAME := pathwaysdos -DB_SCHEMA := pathwaysdos -DB_SECRET_NAME := core-dos-dev/deployment -DB_SECRET_KEY := DB_SF_READONLY_PASSWORD -DB_USER_NAME_SECRET_NAME = uec-dos-int-dev/deployment -DB_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME -TF_VAR_dos_db_name := $(DB_SERVER_NAME) - - -TF_VAR_ip_address_secret := uec-dos-int-dev-ip-addresses-allowlist -SLACK_WEBHOOK_SECRET_NAME = uec-dos-int-dev/deployment -SLACK_WEBHOOK_SECRET_KEY = SLACK_WEBHOOK -SLACK_ALERT_CHANNEL := dos-integration-dev-status diff --git a/build/automation/var/profile/pen.mk b/build/automation/var/profile/pen.mk new file mode 100644 index 000000000..a10263d52 --- /dev/null +++ b/build/automation/var/profile/pen.mk @@ -0,0 +1,5 @@ +-include $(VAR_DIR)/profile/dev.mk + +# WAF +WAF_ENABLED := true +DDB_DELETE_PROTECTION :=false diff --git a/build/automation/var/profile/perf.mk b/build/automation/var/profile/perf.mk index 847d3308b..684c5b842 100644 --- a/build/automation/var/profile/perf.mk +++ b/build/automation/var/profile/perf.mk @@ -1,26 +1,60 @@ --include $(VAR_DIR)/profile/nonprd.mk +-include $(VAR_DIR)/platform-texas/v1/account-live-k8s-nonprod.mk # ============================================================================== # Service variables -LOG_LEVEL:= DEBUG +LOG_LEVEL := INFO -DOS_API_GATEWAY_SECRETS = core-dos-db-sync/deployment -DOS_API_GATEWAY_USERNAME_KEY := DOS_API_GATEWAY_USER -DOS_API_GATEWAY_PASSWORD_KEY := DOS_API_GATEWAY_PASSWORD +# DB Name +DB_CLUSTER_NAME := uec-core-dos-performance-cluster-14 +DB_WRITER_NAME := uec-core-dos-performance-cluster-14-one +DB_READER_NAME := uec-core-dos-performance-cluster-14-two -DOS_API_GATEWAY_URL := https://core-dos-performance-ddc-core-dos-api-gateway.k8s-nonprod.texasplatform.uk/api/change-request +# DB Route 53s +DB_WRITER_ROUTE_53 := uec-core-dos-performance-dos-primary-rw.dos-datastore-performance +DB_READER_ROUTE_53 := uec-core-dos-performance-di-ro.dos-datastore-performance -DB_SERVER_NAME := uec-core-dos-performance-db-12-replica-di +# DB Connection Variables DB_PORT := 5432 DB_NAME := pathwaysdos DB_SCHEMA := pathwaysdos -DB_SECRET_NAME := core-dos-dev/deployment -DB_SECRET_KEY := DB_DI_READONLY_PASSWORD + +# DB Security Groups +DB_SG_NAME := uec-core-dos-performance-datastore-di-sg + +# DB Secrets +DB_WRITER_SECRET_NAME := uec-core-dos-performance-db-di-readwrite-user-password +DB_WRITER_SECRET_KEY := DB_DI_READWRITE_PASSWORD DB_USER_NAME_SECRET_NAME = uec-dos-int-dev/deployment -DB_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME +DB_READER_SECRET_NAME := uec-core-dos-performance-db-di-readonly-user-password +DB_READER_SECRET_KEY := DB_DI_READONLY_PASSWORD +DB_READ_ONLY_USER_NAME_SECRET_NAME = uec-dos-int-dev/deployment +DB_READ_ONLY_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME +# IP Address Secrets TF_VAR_ip_address_secret := uec-dos-int-dev-ip-addresses-allowlist -SLACK_WEBHOOK_SECRET_NAME = uec-dos-int-dev/deployment -SLACK_WEBHOOK_SECRET_KEY = SLACK_WEBHOOK + +# Slack Secrets SLACK_ALERT_CHANNEL := dos-integration-dev-status + +# WAF +WAF_ENABLED := true +DDB_DELETE_PROTECTION :=false + +# ============================================================================== +# Performance variables + +SERVICE_MATCHER_MAX_CONCURRENCY := 28 +SERVICE_SYNC_MAX_CONCURRENCY := 47 + +# ============================================================================== +# DoS DB Handler + +DOS_DEPLOYMENT_SECRETS := null +DOS_DEPLOYMENT_SECRETS_PASSWORD_KEY := null +DOS_DB_HANDLER_DB_READ_AND_WRITE_USER_NAME := null + +# ============================================================================== +# Quality Checker Variables + +ODSCODE_STARTING_CHARACTER := F diff --git a/build/automation/var/profile/perf2.mk b/build/automation/var/profile/perf2.mk new file mode 100644 index 000000000..c549fc222 --- /dev/null +++ b/build/automation/var/profile/perf2.mk @@ -0,0 +1,60 @@ +-include $(VAR_DIR)/platform-texas/v1/account-live-k8s-nonprod.mk + +# ============================================================================== +# Service variables + +LOG_LEVEL := INFO + +# DB Name +DB_CLUSTER_NAME := uec-core-dos-performance2-cluster-14 +DB_WRITER_NAME := uec-core-dos-performance2-cluster-14-two +DB_READER_NAME := uec-core-dos-performance2-cluster-14-one + +# DB Route 53s +DB_WRITER_ROUTE_53 := uec-core-dos-performance2-dos-primary-rw.dos-datastore-performance2 +DB_READER_ROUTE_53 := uec-core-dos-performance2-di-ro.dos-datastore-performance2 + +# DB Connection Variables +DB_PORT := 5432 +DB_NAME := pathwaysdos +DB_SCHEMA := pathwaysdos + +# DB Security Groups +DB_SG_NAME := uec-core-dos-performance2-datastore-di-sg + +# DB Secrets +DB_WRITER_SECRET_NAME := uec-core-dos-performance2-db-di-readwrite-user-password +DB_WRITER_SECRET_KEY := DB_DI_READWRITE_PASSWORD +DB_USER_NAME_SECRET_NAME = uec-dos-int-dev/deployment +DB_READER_SECRET_NAME := uec-core-dos-performance2-db-di-readonly-user-password +DB_READER_SECRET_KEY := DB_DI_READONLY_PASSWORD +DB_READ_ONLY_USER_NAME_SECRET_NAME = uec-dos-int-dev/deployment +DB_READ_ONLY_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME + +# IP Address Secrets +TF_VAR_ip_address_secret := uec-dos-int-dev-ip-addresses-allowlist + +# Slack Secrets +SLACK_ALERT_CHANNEL := dos-integration-dev-status + +# WAF +WAF_ENABLED := true +DDB_DELETE_PROTECTION :=false + +# ============================================================================== +# Performance variables + +SERVICE_MATCHER_MAX_CONCURRENCY := 28 +SERVICE_SYNC_MAX_CONCURRENCY := 47 + +# ============================================================================== +# DoS DB Handler + +DOS_DEPLOYMENT_SECRETS := null +DOS_DEPLOYMENT_SECRETS_PASSWORD_KEY := null +DOS_DB_HANDLER_DB_READ_AND_WRITE_USER_NAME := null + +# ============================================================================== +# Quality Checker Variables + +ODSCODE_STARTING_CHARACTER := F diff --git a/build/automation/var/profile/prod.mk b/build/automation/var/profile/prod.mk deleted file mode 100644 index 27b2161c3..000000000 --- a/build/automation/var/profile/prod.mk +++ /dev/null @@ -1,5 +0,0 @@ --include $(VAR_DIR)/platform-texas/v1/account-live-k8s-prod.mk - -TF_VAR_texas_hosted_zone = $(TEXAS_HOSTED_ZONE) -DOS_INTEGRATION_URL := $(TF_VAR_dos_integration_sub_domain_name).$(TEXAS_HOSTED_ZONE)/v1/change-event -DOS_API_GATEWAY_MOCK_URL := https://$(TF_VAR_dos_api_gateway_name).$(TEXAS_HOSTED_ZONE)/change-request diff --git a/build/automation/var/profile/task.mk b/build/automation/var/profile/task.mk deleted file mode 100644 index 7267c58ad..000000000 --- a/build/automation/var/profile/task.mk +++ /dev/null @@ -1,26 +0,0 @@ --include $(VAR_DIR)/profile/nonprd.mk - -# ============================================================================== -# Service variables - -LOG_LEVEL:= DEBUG - -DOS_API_GATEWAY_SECRETS = $(TF_VAR_dos_api_gateway_secret) -DOS_API_GATEWAY_USERNAME_KEY := DOS_API_GATEWAY_USERNAME -DOS_API_GATEWAY_PASSWORD_KEY := DOS_API_GATEWAY_PASSWORD - -DOS_API_GATEWAY_URL := $(or $(DOS_API_GATEWAY_MOCK_URL), "//") - -DB_SERVER_NAME := uec-core-dos-regression-db-12-replica-di -DB_PORT := 5432 -DB_NAME := pathwaysdos_regression -DB_SCHEMA := pathwaysdos -DB_SECRET_NAME := core-dos-dev/deployment -DB_SECRET_KEY := DB_DI_READONLY_PASSWORD -DB_USER_NAME_SECRET_NAME = uec-dos-int-dev/deployment -DB_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME - -TF_VAR_ip_address_secret := uec-dos-int-task-ip-addresses-allowlist -SLACK_WEBHOOK_SECRET_NAME = uec-dos-int-dev/deployment -SLACK_WEBHOOK_SECRET_KEY = SLACK_WEBHOOK -SLACK_ALERT_CHANNEL := dos-integration-dev-status diff --git a/build/automation/var/profile/test.mk b/build/automation/var/profile/test.mk deleted file mode 100644 index cccd896e6..000000000 --- a/build/automation/var/profile/test.mk +++ /dev/null @@ -1,25 +0,0 @@ --include $(VAR_DIR)/profile/nonprd.mk - -# ============================================================================== -# Service variables - -LOG_LEVEL:= DEBUG - -DOS_API_GATEWAY_SECRETS = core-dos-dev/deployment -DOS_API_GATEWAY_USERNAME_KEY := DOS_API_GATEWAY_USER -DOS_API_GATEWAY_PASSWORD_KEY := DOS_API_GATEWAY_PASSWORD -DOS_API_GATEWAY_URL := https://core-dos-regressiondi-ddc-core-dos-api-gateway.k8s-nonprod.texasplatform.uk/api/change-request - -DB_SERVER_NAME := uec-core-dos-regression-db-12-replica-di -DB_PORT := 5432 -DB_NAME := pathwaysdos_regressiondi -DB_SCHEMA := pathwaysdos -DB_SECRET_NAME := core-dos-dev/deployment -DB_SECRET_KEY := DB_DI_READONLY_PASSWORD -DB_USER_NAME_SECRET_NAME = uec-dos-int-dev/deployment -DB_USER_NAME_SECRET_KEY = DOS_REPLICA_DI_USERNAME - -TF_VAR_ip_address_secret := uec-dos-int-dev-ip-addresses-allowlist -SLACK_WEBHOOK_SECRET_NAME = uec-dos-int-dev/deployment -SLACK_WEBHOOK_SECRET_KEY = SLACK_WEBHOOK -SLACK_ALERT_CHANNEL := dos-integration-dev-status diff --git a/build/automation/var/profile/tools.mk b/build/automation/var/profile/tools.mk index 837eceae6..53f0f1e88 100644 --- a/build/automation/var/profile/tools.mk +++ b/build/automation/var/profile/tools.mk @@ -17,13 +17,7 @@ TERRAFORM_NETWORKING_VPC_ID = 0 TERRAFORM_NETWORKING_ROUTE53_ZONE_NAME = $(PROJECT_GROUP_SHORT).$(TEXAS_TLD_NAME) TERRAFORM_NHSD_IDENTITIES_ACCOUNT_ID = $(AWS_ACCOUNT_ID_IDENTITIES) - -TF_VAR_code_pipeline_branch_name := master -TF_VAR_pipeline_topic_name := $(PROJECT_ID)-$(ENVIRONMENT)-pipeline-topic -TF_VAR_pipeline_notification_name := $(PROJECT_ID)-$(ENVIRONMENT)-pipeline-notification -TF_VAR_pipeline_chatbot_channel := $(PROJECT_ID)-cicd-slk-channel -TF_VAR_nightly_rule_name := $(PROJECT_ID)-$(ENVIRONMENT)-performance-pipeline-nightly-rule - -TF_VAR_lambda_security_group_name := $(PROJECT_ID)-$(ENVIRONMENT)-lambda-sg -TF_VAR_test_signing_key_alias := $(PROJECT_ID)-test-signing-key-alias -TF_VAR_aws_np_vpc_name = lk8s-nonprod.texasplatform.uk +TF_VAR_service_state_bucket = $(TERRAFORM_STATE_STORE) +TF_VAR_development_pipeline_state = $(PROJECT_GROUP_SHORT)-$(PROJECT_NAME_SHORT)/dev/development-pipeline/terraform.state +TF_VAR_mgmt_vpc_name := mgmt.texasplatform.uk +TF_VAR_developer_role = UECDoSINTServiceDeveloper diff --git a/build/automation/var/project.mk b/build/automation/var/project.mk index de5f3b0f0..297f43a26 100644 --- a/build/automation/var/project.mk +++ b/build/automation/var/project.mk @@ -6,122 +6,260 @@ PROJECT_NAME = integration PROJECT_NAME_SHORT = int PROJECT_DISPLAY_NAME = DoS Integration PROJECT_ID = $(PROJECT_GROUP_SHORT)-$(PROJECT_NAME_SHORT) -TEAM_NAME = DoS Integration TEAM_ID = dos-integration -ROLE_PREFIX = UECCommon +ROLE_PREFIX = UECDoSInt PROJECT_TAG = $(PROJECT_NAME) SERVICE_TAG = $(PROJECT_GROUP_SHORT) SERVICE_TAG_COMMON = texas PROJECT_TECH_STACK_LIST = python,terraform - -DOCKER_REPOSITORIES = -SSL_DOMAINS_PROD = -DEPLOYMENT_SECRETS = $(PROJECT_ID)-$(PROFILE)/deployment +PROJECT_LAMBDAS_LIST = $(CHANGE_EVENT_DLQ_HANDLER),$(DOS_DB_HANDLER),$(DOS_DB_UPDATE_DLQ_HANDLER),$(EVENT_REPLAY),$(INGEST_CHANGE_EVENT),$(SEND_EMAIL),$(SERVICE_MATCHER),$(SERVICE_SYNC),$(SLACK_MESSENGER),$(QUALITY_CHECKER) AWS_VPC_NAME = lk8s-$(AWS_ACCOUNT_NAME).texasplatform.uk TF_VAR_aws_vpc_name = $(AWS_VPC_NAME) -SERVERLESS_PYTHON_VERSION_TAG = $(PYTHON_VERSION_MAJOR).$(PYTHON_VERSION_MINOR) -SLS_AWS_MONITORING_FREQUENCY = 1000 +SLS_AWS_MONITORING_FREQUENCY = 500 +TF_VAR_service_name = $(PROJECT_ID) TF_VAR_team_id = $(TEAM_ID) TF_VAR_programme = $(PROGRAMME) TF_VAR_environment = $(ENVIRONMENT) +TF_VAR_aws_account_name = $(AWS_ACCOUNT_NAME) +TF_VAR_deployment_secrets = $(DEPLOYMENT_SECRETS) +PROJECT_DEPLOYMENT_SECRETS = $(DEPLOYMENT_SECRETS) TF_VAR_github_owner = nhsd-exeter TF_VAR_github_repo = dos-integration -PARALLEL_TEST_COUNT := $(or $(PARALLEL_TEST_COUNT) auto) +PARALLEL_TEST_COUNT := $(or $(PARALLEL_TEST_COUNT), auto) -TF_VAR_dos_db_name := $(DB_SERVER_NAME) -ARTEFACTS := cr-fifo-dlq-handler,event-processor,event-replay,event-sender,fifo-dlq-handler,orchestrator -TF_VAR_docker_registry := $(DOCKER_REGISTRY) -DOS_API_GATEWAY_REQUEST_TIMEOUT := 30 -# ============================================================================== -# Infrastructure variables (Terraform, Serverless, etc) -LOG_GROUP_NAME_PROCESSOR := /aws/lambda/$(PROJECT_ID)-$(ENVIRONMENT)-event-processor -LOG_GROUP_NAME_SENDER := /aws/lambda/$(PROJECT_ID)-$(ENVIRONMENT)-event-sender +UNACCEPTABLE_VULNERABILITY_LEVELS = CRITICAL,HIGH,MEDIUM -# Change Event Receiver API Gateway API Keys -TF_VAR_api_gateway_api_key_name := $(PROJECT_ID)-$(ENVIRONMENT)-api-key -TF_VAR_nhs_uk_api_key_key := NHS_UK_API_KEY +BLUE_GREEN_ENVIRONMENT := $(or $(BLUE_GREEN_ENVIRONMENT), $(ENVIRONMENT)) +SHARED_ENVIRONMENT := $(or $(SHARED_ENVIRONMENT), $(ENVIRONMENT)) +TF_VAR_blue_green_environment := $(BLUE_GREEN_ENVIRONMENT) +TF_VAR_shared_environment := $(SHARED_ENVIRONMENT) -# Lambda Security Group -TF_VAR_lambda_security_group_name := $(PROJECT_ID)-$(ENVIRONMENT)-lambda-sg +# Development and Deployment Tools +TF_VAR_cicd_blue_green_deployment_pipeline_artefact_bucket := $(PROJECT_ID)-$(ENVIRONMENT)-cicd-blue-green-deployment-artefacts +TF_VAR_cicd_shared_resoures_deployment_pipeline_artefact_bucket := $(PROJECT_ID)-$(ENVIRONMENT)-cicd-shared-resources-deployment-artefacts +TF_VAR_development_pipeline_branch_name := develop +TF_VAR_cicd_blue_green_deployment_pipeline_name := $(PROJECT_ID)-$(ENVIRONMENT)-cicd-blue-green-deployment-pipeline +TF_VAR_cicd_shared_resources_deployment_pipeline_name := $(PROJECT_ID)-$(ENVIRONMENT)-cicd-shared-resources-deployment-pipeline +TF_VAR_blue_green_deployment_new_version_parameter_name := $(PROJECT_ID)-$(ENVIRONMENT)-blue-green-deployment-new-version +TF_VAR_development_tools_encryption_key_alias := $(PROJECT_ID)-$(ENVIRONMENT)-development-tools-encryption-key +TF_VAR_github_url := https://github.com/nhsd-exeter/dos-integration.git +TF_VAR_pipeline_topic_name := $(PROJECT_ID)-$(ENVIRONMENT)-pipeline-topic +TF_VAR_pipeline_notification_name := $(PROJECT_ID)-$(ENVIRONMENT)-pipeline-notification +TF_VAR_cicd_blue_green_deployment_pipeline_nofitication_name := $(PROJECT_ID)-$(ENVIRONMENT)-blue-green-pipeline-notification +TF_VAR_cicd_shared_resources_deployment_pipeline_nofitication_name := $(PROJECT_ID)-$(ENVIRONMENT)-shared-resources-pipeline-notification +TF_VAR_pipeline_chatbot_channel := $(PROJECT_ID)-cicd-slk-channel +TF_VAR_nightly_rule_name := $(PROJECT_ID)-$(ENVIRONMENT)-performance-pipeline-nightly-rule -# Change Event Receiver API Gateway Route53 & SQS -TF_VAR_dos_integration_sub_domain_name := $(PROGRAMME)-$(TEAM_ID)-$(ENVIRONMENT) -DOS_INTEGRATION_URL := $(TF_VAR_dos_integration_sub_domain_name).$(TEXAS_HOSTED_ZONE)/v1/change-event -TF_VAR_di_endpoint_api_gateway_name := $(PROJECT_ID)-$(ENVIRONMENT)-di-endpoint -TF_VAR_di_endpoint_api_gateway_stage := $(ENVIRONMENT) -TF_VAR_fifo_queue_name := $(PROJECT_ID)-$(ENVIRONMENT)-fifo-queue.fifo +# ============================================================================== +# Infrastructure variables (Terraform, etc) +# ------------------------------- +# Common variables for all environments -TF_VAR_cr_fifo_queue_name := $(PROJECT_ID)-$(ENVIRONMENT)-cr-fifo-queue.fifo +# General +TF_VAR_docker_registry := $(DOCKER_REGISTRY) -cr_fifo_queue_url := https://sqs.$(AWS_REGION).amazonaws.com/$(AWS_ACCOUNT_ID)/$(TF_VAR_cr_fifo_queue_name) -cr_dlq_queue_url := https://sqs.$(AWS_REGION).amazonaws.com/$(AWS_ACCOUNT_ID)/$(TF_VAR_cr_dead_letter_queue_from_fifo_queue_name) -TF_VAR_dead_letter_queue_from_fifo_queue_name := $(PROJECT_ID)-$(ENVIRONMENT)-dead-letter-queue.fifo -TF_VAR_cr_dead_letter_queue_from_fifo_queue_name := $(PROJECT_ID)-$(ENVIRONMENT)-cr-dead-letter-queue.fifo +# Tags +SERVICE_CATEGORY_KEY := DI_SERVICE_CATEGORY +DATA_CLASSIFICATION_KEY := DI_DATA_CLASSIFICATION +DISTRIBUTION_LIST_KEY := DI_DISTRIBUTION_LIST -# Dynamodb -TF_VAR_change_events_table_name := $(PROJECT_ID)-$(ENVIRONMENT)-change-events - -# Lambda IAM Roles -TF_VAR_event_processor_role_name := $(PROJECT_ID)-$(ENVIRONMENT)-event-processor-role -TF_VAR_event_sender_role_name := $(PROJECT_ID)-$(ENVIRONMENT)-event-sender-role -TF_VAR_fifo_dlq_handler_role_name := $(PROJECT_ID)-$(ENVIRONMENT)-fifo-dlq-handler-role -TF_VAR_event_replay_role_name := $(PROJECT_ID)-$(ENVIRONMENT)-event-replay-role -TF_VAR_cr_fifo_dlq_handler_role_name := $(PROJECT_ID)-$(ENVIRONMENT)-cr-fifo-dlq-handler-role -TF_VAR_test_db_checker_handler_role_name := $(PROJECT_ID)-$(ENVIRONMENT)-test-db-checker-handler-role -TF_VAR_orchestrator_role_name := $(PROJECT_ID)-$(ENVIRONMENT)-orchestrator-role -TF_VAR_slack_messenger_role_name := $(PROJECT_ID)-$(ENVIRONMENT)-slack-messenger-role +# Global region +TF_VAR_route53_health_check_alarm_region = us-east-1 # Kinisis Firehose (Splunk Logs) TF_VAR_dos_integration_firehose := $(PROJECT_ID)-cw-logs-firehose -TF_VAR_firehose_role := $(PROJECT_ID)_cw_firehose_access_role -TF_VAR_event_processor_subscription_filter_name := $(PROJECT_ID)-$(ENVIRONMENT)-event-processor-cw-logs-firehose-subscription -TF_VAR_event_sender_subscription_filter_name := $(PROJECT_ID)-$(ENVIRONMENT)-event-sender-cw-logs-firehose-subscription -TF_VAR_orchestrator_subscription_filter_name := $(PROJECT_ID)-$(ENVIRONMENT)-orchestrator-cw-logs-firehose-subscription -TF_VAR_change_event_gateway_subscription_filter_name := $(PROJECT_ID)-$(ENVIRONMENT)-change-event-api-gateway-cw-logs-firehose-subscription -TF_VAR_fifo_dlq_handler_subscription_filter_name := $(PROJECT_ID)-$(ENVIRONMENT)-fifo-dlq-handler-cw-logs-firehose-subscription -TF_VAR_cr_fifo_dlq_handler_subscription_filter_name := $(PROFILE_ID)-$(ENVIRONMENT)-cr-fifo-dlq-handler-cw-logs-firehose-subscription -TF_VAR_event_replay_subscription_filter_name := $(PROFILE_ID)-$(ENVIRONMENT)-eventbridge-dlq-handler-cw-logs-firehose-subscription -TF_VAR_event_processor_lambda_name := $(PROJECT_ID)-$(ENVIRONMENT)-event-processor -TF_VAR_event_sender_lambda_name := $(PROJECT_ID)-$(ENVIRONMENT)-event-sender -TF_VAR_fifo_dlq_handler_lambda_name := $(PROJECT_ID)-$(ENVIRONMENT)-fifo-dlq-handler -TF_VAR_event_replay_lambda_name := $(PROJECT_ID)-$(ENVIRONMENT)-event-replay -TF_VAR_test_db_checker_lambda_name := $(PROJECT_ID)-$(ENVIRONMENT)-test-db-checker-handler -TF_VAR_cr_fifo_dlq_handler_lambda_name := $(PROJECT_ID)-$(ENVIRONMENT)-cr-fifo-dlq-handler -TF_VAR_orchestrator_lambda_name := $(PROJECT_ID)-$(ENVIRONMENT)-orchestrator -TF_VAR_change_request_gateway_subscription_filter_name := $(PROJECT_ID)-$(ENVIRONMENT)-change-request-api-gateway-cw-logs-firehose-subscription - -# Mock DoS API Gateway Mock -TF_VAR_dos_api_gateway_name := $(PROJECT_ID)-$(ENVIRONMENT)-dos-api-gateway-mock -TF_VAR_dos_api_gateway_stage := $(ENVIRONMENT) -TF_VAR_dos_api_gateway_authoriser := $(PROJECT_ID)-$(ENVIRONMENT)-dos-api-gateway-mock-authoriser -TF_VAR_authoriser_lambda_name := $(PROJECT_ID)-$(ENVIRONMENT)-authoriser -TF_VAR_image_version := $(or $(VERSION), latest) -TF_VAR_dos_api_gateway_secret := $(PROJECT_ID)-$(ENVIRONMENT)-dos-api-gateway-mock-secret -TF_VAR_dos_api_gateway_secret_username_key := $(DOS_API_GATEWAY_USERNAME_KEY) -TF_VAR_dos_api_gateway_secret_password_key := $(DOS_API_GATEWAY_PASSWORD_KEY) -TF_VAR_dos_api_gateway_lambda_name := $(PROJECT_ID)-$(ENVIRONMENT)-dos-api-gateway-lambda -TF_VAR_powertools_service_name := $(PROGRAMME)-$(TEAM_ID)-$(ENVIRONMENT) - - -TF_VAR_signing_key_alias := $(PROJECT_ID)-$(ENVIRONMENT)-signing-key-alias +TF_VAR_di_firehose_role := $(PROJECT_ID)_cw_firehose_access_role +TF_VAR_dos_firehose := dos-cw-logs-firehose +TF_VAR_dos_firehose_role := dos_cw_firehose_access_role + +CHANGE_EVENT_DLQ_HANDLER := change-event-dlq-handler +DOS_DB_HANDLER := dos-db-handler +DOS_DB_UPDATE_DLQ_HANDLER := dos-db-update-dlq-handler +EVENT_REPLAY := event-replay +INGEST_CHANGE_EVENT := ingest-change-event +SEND_EMAIL := send-email +SERVICE_MATCHER := service-matcher +SERVICE_SYNC := service-sync +SLACK_MESSENGER := slack-messenger +QUALITY_CHECKER := quality-checker + +# ------------------------------- +# SHARED ENVIRONMENT VARIABLES + +# DI Endpoint API Gateway API Keys +TF_VAR_api_gateway_api_key_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-api-key +TF_VAR_nhs_uk_api_key_key := NHS_UK_API_KEY +API_KEY_SECRET := $(TF_VAR_api_gateway_api_key_name) +NHS_UK_API_KEY := $(TF_VAR_nhs_uk_api_key_key) + +# DI Endpoint API Gateway Route53 & SQS +TF_VAR_dos_integration_sub_domain_name := $(PROGRAMME)-$(TEAM_ID)-$(SHARED_ENVIRONMENT) +DOS_INTEGRATION_URL = $(TF_VAR_dos_integration_sub_domain_name).$(TEXAS_HOSTED_ZONE)/v1/change-event +HTTPS_DOS_INTEGRATION_URL = https://$(DOS_INTEGRATION_URL) +TF_VAR_di_endpoint_api_gateway_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-di-endpoint +TF_VAR_di_endpoint_api_gateway_stage := $(SHARED_ENVIRONMENT) + +# SQS Queues +TF_VAR_change_event_queue := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-change-event-queue.fifo +TF_VAR_change_event_dlq := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-change-event-dead-letter-queue.fifo + +# Dynamodb +TF_VAR_change_events_table_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-change-events +DYNAMO_DB_TABLE := $(TF_VAR_change_events_table_name) +TF_VAR_ddb_delete_protection :=$(DDB_DELETE_PROTECTION) + +# Log Group Filters for Firehose +TF_VAR_change_event_gateway_subscription_filter_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-change-event-api-gateway-cw-logs-firehose-subscription + +# KMS +TF_VAR_signing_key_alias := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-signing-key-alias +TF_VAR_route53_health_check_alarm_region_signing_key_alias := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-alarm-region-signing-key-alias + +# S3 +SEND_EMAIL_BUCKET_NAME := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-send-email-bucket +TF_VAR_send_email_bucket_name := $(SEND_EMAIL_BUCKET_NAME) +TF_VAR_logs_bucket_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-logs-bucket # Cloudwatch monitoring dashboard -TF_VAR_cloudwatch_monitoring_dashboard_name := $(PROJECT_ID)-$(ENVIRONMENT)-monitoring-dashboard -TF_VAR_sqs_dlq_recieved_msg_alert_name := $(PROJECT_ID)-$(ENVIRONMENT)-sqs-dlq-recieved-msg-alert -TF_VAR_sns_topic_app_alerts_for_slack := $(PROJECT_ID)-$(ENVIRONMENT)-topic-app-alerts-for-slack +TF_VAR_shared_resources_sns_topic_app_alerts_for_slack_default_region := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-shared-resources-topic-app-alerts-for-slack-default-region +TF_VAR_shared_resources_sns_topic_app_alerts_for_slack_route53_health_check_alarm_region := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-shared-resources-topic-app-alerts-for-slack-route53-health-check-alarm-region -SQS_QUEUE_URL:= https://sqs.$(AWS_REGION).amazonaws.com/$(AWS_ACCOUNT_ID)/$(TF_VAR_fifo_queue_name) -DOS_TRANSACTIONS_PER_SECOND=3 +# WAF +TF_VAR_waf_enabled := $(WAF_ENABLED) +TF_VAR_waf_acl_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-acl +TF_VAR_waf_log_group_name := aws-waf-logs-$(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-log-group +TF_VAR_waf_log_subscription_filter_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-log-subscription-filter +TF_VAR_waf_ip_set_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-ip-set -# Performance Pipelines -TF_VAR_code_pipeline_branch_name := master -TF_VAR_pipeline_topic_name := $(PROJECT_ID)-$(ENVIRONMENT)-pipeline-topic -TF_VAR_pipeline_notification_name := $(PROJECT_ID)-$(ENVIRONMENT)-pipeline-notification -TF_VAR_pipeline_chatbot_channel := $(PROJECT_ID)-cicd-slk-channel -TF_VAR_nightly_rule_name := $(PROJECT_ID)-$(ENVIRONMENT)-performance-pipeline-nightly-rule +TF_VAR_waf_aws_common_rule_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-aws-common-rule +TF_VAR_waf_ip_reputation_list_rule_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-ip-reputation-list-rule +TF_VAR_waf_non_gb_rule_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-non-gb-rule +TF_VAR_waf_ip_allow_list_rule_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-ip-allow-list-rule +TF_VAR_waf_rate_based_rule_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-rate-based-rule +TF_VAR_waf_aws_known_bad_inputs_rule_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-aws-known-bad-inputs-rule +TF_VAR_waf_aws_sqli_rule_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-aws-sqli-rule +TF_VAR_waf_custom_sqli_rule_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-waf-custom-sqli-rule + +# ------------------------------- +# BLUE/GREEN ENVIRONMENT VARIABLES + +# Lambda Security Group +TF_VAR_lambda_security_group_name := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-lambda-sg +TF_VAR_db_sg_name := $(DB_SG_NAME) + +# SQS Queues +TF_VAR_holding_queue := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-holding-queue.fifo +TF_VAR_update_request_queue := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-update-request-queue.fifo +TF_VAR_holding_queue_dlq := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-holding-queue-dead-letter-queue.fifo +TF_VAR_update_request_dlq := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-update-request-dead-letter-queue.fifo + +# Docker Image Names +TF_VAR_change_event_dlq_handler := $(CHANGE_EVENT_DLQ_HANDLER) +TF_VAR_dos_db_handler := $(DOS_DB_HANDLER) +TF_VAR_dos_db_update_dlq_handler := $(DOS_DB_UPDATE_DLQ_HANDLER) +TF_VAR_event_replay := $(EVENT_REPLAY) +TF_VAR_ingest_change_event := $(INGEST_CHANGE_EVENT) +TF_VAR_send_email := $(SEND_EMAIL) +TF_VAR_service_matcher := $(SERVICE_MATCHER) +TF_VAR_service_sync := $(SERVICE_SYNC) +TF_VAR_slack_messenger := $(SLACK_MESSENGER) +TF_VAR_quality_checker := $(QUALITY_CHECKER) + +# Lambda names +CHANGE_EVENT_DLQ_HANDLER_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(CHANGE_EVENT_DLQ_HANDLER) +DOS_DB_HANDLER_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(DOS_DB_HANDLER) +DOS_DB_UPDATE_DLQ_HANDLER_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(DOS_DB_UPDATE_DLQ_HANDLER) +EVENT_REPLAY_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(EVENT_REPLAY) +INGEST_CHANGE_EVENT_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(INGEST_CHANGE_EVENT) +SEND_EMAIL_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(SEND_EMAIL) +SERVICE_MATCHER_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(SERVICE_MATCHER) +SERVICE_SYNC_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(SERVICE_SYNC) +SLACK_MESSENGER_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(SLACK_MESSENGER) +QUALITY_CHECKER_LAMBDA := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-$(QUALITY_CHECKER) + +TF_VAR_change_event_dlq_handler_lambda := $(CHANGE_EVENT_DLQ_HANDLER_LAMBDA) +TF_VAR_dos_db_handler_lambda := $(DOS_DB_HANDLER_LAMBDA) +TF_VAR_dos_db_update_dlq_handler_lambda := $(DOS_DB_UPDATE_DLQ_HANDLER_LAMBDA) +TF_VAR_event_replay_lambda := $(EVENT_REPLAY_LAMBDA) +TF_VAR_ingest_change_event_lambda := $(INGEST_CHANGE_EVENT_LAMBDA) +TF_VAR_send_email_lambda := $(SEND_EMAIL_LAMBDA) +TF_VAR_service_matcher_lambda := $(SERVICE_MATCHER_LAMBDA) +TF_VAR_service_sync_lambda := $(SERVICE_SYNC_LAMBDA) +TF_VAR_slack_messenger_lambda := $(SLACK_MESSENGER_LAMBDA) +TF_VAR_quality_checker_lambda := $(QUALITY_CHECKER_LAMBDA) + +# Lambda Versions +TF_VAR_change_event_dlq_handler_version := $(or $(CHANGE_EVENT_DLQ_HANDLER_VERSION), $(VERSION)) +TF_VAR_dos_db_handler_version := $(or $(DOS_DB_HANDLER_VERSION), $(VERSION)) +TF_VAR_dos_db_update_dlq_handler_version := $(or $(DOS_DB_UPDATE_DLQ_HANDLER_VERSION), $(VERSION)) +TF_VAR_event_replay_version := $(or $(EVENT_REPLAY_VERSION), $(VERSION)) +TF_VAR_ingest_change_event_version := $(or $(INGEST_CHANGE_EVENT_VERSION), $(VERSION)) +TF_VAR_send_email_version := $(or $(SEND_EMAIL_VERSION), $(VERSION)) +TF_VAR_service_matcher_version := $(or $(SERVICE_MATCHER_VERSION), $(VERSION)) +TF_VAR_service_sync_version := $(or $(SERVICE_SYNC_VERSION), $(VERSION)) +TF_VAR_slack_messenger_version := $(or $(SLACK_MESSENGER_VERSION), $(VERSION)) +TF_VAR_quality_checker_version := $(or $(QUALITY_CHECKER_VERSION), $(VERSION)) + +TF_VAR_change_event_dlq_handler_role := $(CHANGE_EVENT_DLQ_HANDLER_LAMBDA_ROLE_NAME) +TF_VAR_dos_db_handler_role := $(DOS_DB_HANDLER_LAMBDA_ROLE_NAME) +TF_VAR_dos_db_update_dlq_handler_role := $(DOS_DB_UPDATE_DLQ_HANDLER_LAMBDA_ROLE_NAME) +TF_VAR_event_replay_role := $(EVENT_REPLAY_LAMBDA_ROLE_NAME) +TF_VAR_ingest_change_event_role := $(INGEST_CHANGE_EVENT_LAMBDA_ROLE_NAME) +TF_VAR_send_email_role := $(SEND_EMAIL_LAMBDA_ROLE_NAME) +TF_VAR_service_matcher_role := $(SERVICE_MATCHER_LAMBDA_ROLE_NAME) +TF_VAR_service_sync_role := $(SERVICE_SYNC_LAMBDA_ROLE_NAME) +TF_VAR_slack_messenger_role := $(SLACK_MESSENGER_LAMBDA_ROLE_NAME) +TF_VAR_quality_checker_role := $(QUALITY_CHECKER_LAMBDA_ROLE_NAME) + +# Log Group Filters for Firehose +TF_VAR_change_event_dlq_handler_subscription_filter_name := $(CHANGE_EVENT_DLQ_HANDLER_LAMBDA)-cw-logs-firehose-subscription +TF_VAR_dos_db_update_dlq_handler_subscription_filter_name := $(DOS_DB_HANDLER_LAMBDA)-cw-logs-firehose-subscription +TF_VAR_event_replay_subscription_filter_name := $(EVENT_REPLAY_LAMBDA)-cw-logs-firehose-subscription +TF_VAR_ingest_change_event_subscription_filter_name := $(INGEST_CHANGE_EVENT_LAMBDA)-cw-logs-firehose-subscription +TF_VAR_send_email_subscription_filter_name := $(SEND_EMAIL_LAMBDA)-cw-logs-firehose-subscription +TF_VAR_service_matcher_subscription_filter_name := $(SERVICE_MATCHER_LAMBDA)-cw-logs-firehose-subscription +TF_VAR_service_sync_di_subscription_filter_name := $(SERVICE_SYNC_LAMBDA)-di-cw-logs-firehose-subscription +TF_VAR_service_sync_dos_subscription_filter_name := $(SERVICE_SYNC_LAMBDA)-dos-cw-logs-firehose-subscription +TF_VAR_slack_messenger_subscription_filter_name := $(SLACK_MESSENGER_LAMBDA)-cw-logs-firehose-subscription +TF_VAR_quality_checker_subscription_filter_name := $(QUALITY_CHECKER_LAMBDA)-cw-logs-firehose-subscription + +# Cloudwatch dashboards +TF_VAR_cloudwatch_monitoring_dashboard_name := $(PROJECT_ID)-$(SHARED_ENVIRONMENT)-monitoring-dashboard +TF_VAR_sqs_dlq_recieved_msg_alert_name := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-sqs-dlq-recieved-msg-alert +TF_VAR_sns_topic_app_alerts_for_slack_default_region := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-topic-app-alerts-for-slack-default-region +TF_VAR_sns_topic_app_alerts_for_slack_route53_health_check_alarm_region := $(PROJECT_ID)-$(BLUE_GREEN_ENVIRONMENT)-topic-app-alerts-for-slack-route53-health-check-alarm-region +SQS_QUEUE_URL:= https://sqs.$(AWS_REGION).amazonaws.com/$(AWS_ACCOUNT_ID)/$(TF_VAR_change_event_queue) + +# Lambda Concurrency +TF_VAR_service_matcher_max_concurrency := $(SERVICE_MATCHER_MAX_CONCURRENCY) +TF_VAR_service_sync_max_concurrency := $(SERVICE_SYNC_MAX_CONCURRENCY) + +# Lambda Variables +TF_VAR_log_level := $(LOG_LEVEL) +TF_VAR_lambda_powertools_service_name := $(PROGRAMME)-$(TEAM_ID)-$(PROFILE)-$(BLUE_GREEN_ENVIRONMENT) +TF_VAR_slack_alert_channel := $(SLACK_ALERT_CHANNEL) +TF_VAR_dos_db_cluster_name := $(DB_CLUSTER_NAME) +TF_VAR_dos_db_writer_name := $(DB_WRITER_NAME) +TF_VAR_dos_db_reader_name := $(DB_READER_NAME) +TF_VAR_dos_db_writer_route_53 := $(DB_WRITER_ROUTE_53) +TF_VAR_dos_db_reader_route_53 := $(DB_READER_ROUTE_53) +TF_VAR_dos_db_port := $(DB_PORT) +TF_VAR_dos_db_name := $(DB_NAME) +TF_VAR_dos_db_schema := $(DB_SCHEMA) +TF_VAR_dos_db_writer_security_group_name := $(DB_WRITER_SG_NAME) +TF_VAR_dos_db_reader_security_group_name := $(DB_READER_SG_NAME) +TF_VAR_dos_db_writer_secret_name := $(DB_WRITER_SECRET_NAME) +TF_VAR_dos_db_writer_secret_key := $(DB_WRITER_SECRET_KEY) +TF_VAR_dos_db_reader_secret_name := $(DB_READER_SECRET_NAME) +TF_VAR_dos_db_reader_secret_key := $(DB_READER_SECRET_KEY) +TF_VAR_dos_db_read_only_user_name_secret_name := $(DB_READ_ONLY_USER_NAME_SECRET_NAME) +TF_VAR_dos_db_read_only_user_name_secret_key := $(DB_READ_ONLY_USER_NAME_SECRET_KEY) +TF_VAR_slack_webhook_secret_key := $(SLACK_WEBHOOK_SECRET_KEY) +TF_VAR_odscode_starting_character := $(ODSCODE_STARTING_CHARACTER) -# AppConfig -TF_VAR_accepted_org_types = $(ACCEPTED_ORG_TYPES) +# Lambda Schedules +TF_VAR_quality_checker_schedule_role := $(QUALITY_CHECKER_LAMBDA)-schedule-role +TF_VAR_quality_checker_lambda_schedule_name := $(QUALITY_CHECKER_LAMBDA)-schedule diff --git a/build/docker/authoriser/.gitignore b/build/docker/authoriser/.gitignore deleted file mode 100644 index 866918d6c..000000000 --- a/build/docker/authoriser/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.version -*.txt -*.tar.gz -*.txt -Dockerfile.effective diff --git a/build/docker/cr-fifo-dlq-handler/.gitignore b/build/docker/cr-fifo-dlq-handler/.gitignore deleted file mode 100644 index 866918d6c..000000000 --- a/build/docker/cr-fifo-dlq-handler/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.version -*.txt -*.tar.gz -*.txt -Dockerfile.effective diff --git a/build/docker/cr-fifo-dlq-handler/Dockerfile b/build/docker/cr-fifo-dlq-handler/Dockerfile deleted file mode 100644 index e79c85f66..000000000 --- a/build/docker/cr-fifo-dlq-handler/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 -# Copy Python requirements -COPY assets/requirements.txt / -# Install Python requirements -RUN pip install -r /requirements.txt --upgrade -# Copy function code -ADD assets/cr-fifo-dlq-handler-app.tar.gz "/var/task" -# Set the CMD to your handler -CMD [ "cr_fifo_dlq_handler.lambda_handler" ] diff --git a/build/docker/cr-fifo-dlq-handler/VERSION b/build/docker/cr-fifo-dlq-handler/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/cr-fifo-dlq-handler/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/dos-api-gateway/.gitignore b/build/docker/dos-api-gateway/.gitignore deleted file mode 100644 index 866918d6c..000000000 --- a/build/docker/dos-api-gateway/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.version -*.txt -*.tar.gz -*.txt -Dockerfile.effective diff --git a/build/docker/dos-api-gateway/Dockerfile b/build/docker/dos-api-gateway/Dockerfile deleted file mode 100644 index 1d2802725..000000000 --- a/build/docker/dos-api-gateway/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 -# Copy Python requirements -COPY assets/requirements.txt / -# Install Python requirements -RUN pip install -r /requirements.txt --upgrade -# Copy function code -ADD assets/dos-api-gateway-app.tar.gz "/var/task" -# Set the CMD to your handler -CMD [ "dos_api_gateway.lambda_handler" ] diff --git a/build/docker/dos-api-gateway/VERSION b/build/docker/dos-api-gateway/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/dos-api-gateway/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/event-processor/.gitignore b/build/docker/event-processor/.gitignore deleted file mode 100644 index 866918d6c..000000000 --- a/build/docker/event-processor/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.version -*.txt -*.tar.gz -*.txt -Dockerfile.effective diff --git a/build/docker/event-processor/Dockerfile b/build/docker/event-processor/Dockerfile deleted file mode 100644 index 3c1c74b4c..000000000 --- a/build/docker/event-processor/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 -# Install Python requirements -COPY assets/requirements.txt / -# Install Python requirements -RUN python -m pip install -r /requirements.txt --upgrade --target="/var/task" -# Copy function code -ADD assets/event-processor-app.tar.gz "/var/task" -# Set the CMD to your handler -CMD [ "event_processor.lambda_handler" ] diff --git a/build/docker/event-processor/VERSION b/build/docker/event-processor/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/event-processor/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/event-replay/.gitignore b/build/docker/event-replay/.gitignore deleted file mode 100644 index 866918d6c..000000000 --- a/build/docker/event-replay/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.version -*.txt -*.tar.gz -*.txt -Dockerfile.effective diff --git a/build/docker/event-replay/Dockerfile b/build/docker/event-replay/Dockerfile deleted file mode 100644 index 97a4caa96..000000000 --- a/build/docker/event-replay/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 -# Copy Python requirements -COPY assets/requirements.txt / -# Install Python requirements -RUN pip install -r /requirements.txt --upgrade -# Copy function code -ADD assets/event-replay-app.tar.gz "/var/task" -# Set the CMD to your handler -CMD [ "event_replay.lambda_handler" ] diff --git a/build/docker/event-replay/VERSION b/build/docker/event-replay/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/event-replay/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/event-sender/Dockerfile b/build/docker/event-sender/Dockerfile deleted file mode 100644 index c7c3da6eb..000000000 --- a/build/docker/event-sender/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 -# Copy Python requirements -COPY assets/requirements.txt / -# Install Python requirements -RUN pip install -r /requirements.txt --upgrade -# Copy function code -ADD assets/event-sender-app.tar.gz "/var/task" -# Set the CMD to your handler -CMD [ "event_sender.lambda_handler" ] diff --git a/build/docker/event-sender/VERSION b/build/docker/event-sender/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/event-sender/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/fifo-dlq-handler/.gitignore b/build/docker/fifo-dlq-handler/.gitignore deleted file mode 100644 index 866918d6c..000000000 --- a/build/docker/fifo-dlq-handler/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.version -*.txt -*.tar.gz -*.txt -Dockerfile.effective diff --git a/build/docker/fifo-dlq-handler/Dockerfile b/build/docker/fifo-dlq-handler/Dockerfile deleted file mode 100644 index f1f74f126..000000000 --- a/build/docker/fifo-dlq-handler/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 -# Copy Python requirements -COPY assets/requirements.txt / -# Install Python requirements -RUN pip install -r /requirements.txt --upgrade -# Copy function code -ADD assets/fifo-dlq-handler-app.tar.gz "/var/task" -# Set the CMD to your handler -CMD [ "fifo_dlq_handler.lambda_handler" ] diff --git a/build/docker/fifo-dlq-handler/VERSION b/build/docker/fifo-dlq-handler/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/fifo-dlq-handler/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/event-sender/.gitignore b/build/docker/lambda/.gitignore similarity index 100% rename from build/docker/event-sender/.gitignore rename to build/docker/lambda/.gitignore diff --git a/build/docker/authoriser/Dockerfile b/build/docker/lambda/Dockerfile similarity index 54% rename from build/docker/authoriser/Dockerfile rename to build/docker/lambda/Dockerfile index 01959d08d..333ad4347 100644 --- a/build/docker/authoriser/Dockerfile +++ b/build/docker/lambda/Dockerfile @@ -1,9 +1,11 @@ -FROM public.ecr.aws/lambda/python:3.9 +FROM public.ecr.aws/lambda/python:3.12-arm64 +# Update Pip +RUN python -m pip install --upgrade pip # Copy Python requirements COPY assets/requirements.txt / # Install Python requirements RUN pip install -r /requirements.txt --upgrade # Copy function code -ADD assets/authoriser-app.tar.gz "/var/task" +ADD assets/app.tar.gz "/var/task" # Set the CMD to your handler -CMD [ "authoriser.lambda_handler" ] +CMD [ "CMD_TO_REPLACE" ] diff --git a/build/docker/authoriser/VERSION b/build/docker/lambda/VERSION similarity index 100% rename from build/docker/authoriser/VERSION rename to build/docker/lambda/VERSION diff --git a/build/docker/event-processor/assets/.gitkeep b/build/docker/lambda/assets/.gitkeep similarity index 100% rename from build/docker/event-processor/assets/.gitkeep rename to build/docker/lambda/assets/.gitkeep diff --git a/build/docker/orchestrator/.gitignore b/build/docker/orchestrator/.gitignore deleted file mode 100644 index 866918d6c..000000000 --- a/build/docker/orchestrator/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.version -*.txt -*.tar.gz -*.txt -Dockerfile.effective diff --git a/build/docker/orchestrator/Dockerfile b/build/docker/orchestrator/Dockerfile deleted file mode 100644 index c739e5ea4..000000000 --- a/build/docker/orchestrator/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 -# Copy Python requirements -COPY assets/requirements.txt / -# Install Python requirements -RUN pip install -r /requirements.txt --upgrade -# Copy function code -ADD assets/orchestrator-app.tar.gz "/var/task" -# Set the CMD to your handler -CMD [ "orchestrator.lambda_handler" ] diff --git a/build/docker/orchestrator/VERSION b/build/docker/orchestrator/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/orchestrator/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/serverless/.gitignore b/build/docker/serverless/.gitignore deleted file mode 100644 index f3c12275d..000000000 --- a/build/docker/serverless/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.version -*.tar.gz -Dockerfile.effective diff --git a/build/docker/serverless/Dockerfile b/build/docker/serverless/Dockerfile deleted file mode 100644 index eb088f994..000000000 --- a/build/docker/serverless/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM node:latest -WORKDIR / -# Install serverless -RUN apk add python3 make gcc g++ -RUN export PATH="$PATH:$(yarn global bin)" -RUN yarn global add serverless@3.* -# Install plugins -ADD assets/serverless.tar.gz /deployment/ -RUN chmod -R 777 /deployment -WORKDIR /deployment -RUN serverless plugin install -n serverless-vpc-discovery diff --git a/build/docker/serverless/VERSION b/build/docker/serverless/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/serverless/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/slack-messenger/.gitignore b/build/docker/slack-messenger/.gitignore deleted file mode 100644 index 957eaef4d..000000000 --- a/build/docker/slack-messenger/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.version -*.tar.gz -*.txt -Dockerfile.effective diff --git a/build/docker/slack-messenger/Dockerfile b/build/docker/slack-messenger/Dockerfile deleted file mode 100644 index 8860227d8..000000000 --- a/build/docker/slack-messenger/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 -# Copy Python requirements -COPY assets/requirements.txt / -# Install Python requirements -RUN pip install -r /requirements.txt --upgrade -# Copy function code -ADD assets/slack-messenger-app.tar.gz "/var/task" -# Set the CMD to your handler -CMD [ "slack_messenger.lambda_handler" ] diff --git a/build/docker/slack-messenger/VERSION b/build/docker/slack-messenger/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/slack-messenger/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/test-db-checker-handler/.gitignore b/build/docker/test-db-checker-handler/.gitignore deleted file mode 100644 index 866918d6c..000000000 --- a/build/docker/test-db-checker-handler/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.version -*.txt -*.tar.gz -*.txt -Dockerfile.effective diff --git a/build/docker/test-db-checker-handler/Dockerfile b/build/docker/test-db-checker-handler/Dockerfile deleted file mode 100644 index f59277732..000000000 --- a/build/docker/test-db-checker-handler/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM public.ecr.aws/lambda/python:3.9 -# Copy Python requirements -COPY assets/requirements.txt / -# Install Python requirements -RUN pip install -r /requirements.txt --upgrade -# Copy function code -ADD assets/test-db-checker-handler-app.tar.gz "/var/task" -# Set the CMD to your handler -CMD [ "test_db_checker_handler.lambda_handler" ] diff --git a/build/docker/test-db-checker-handler/VERSION b/build/docker/test-db-checker-handler/VERSION deleted file mode 100644 index bb52c64a3..000000000 --- a/build/docker/test-db-checker-handler/VERSION +++ /dev/null @@ -1 +0,0 @@ -YYYYmmddHHMMss-hash diff --git a/build/docker/tester/Dockerfile b/build/docker/tester/Dockerfile index e2a5da577..11ee89a74 100644 --- a/build/docker/tester/Dockerfile +++ b/build/docker/tester/Dockerfile @@ -1,6 +1,6 @@ -FROM python:3.9 -# Install python package build requirements -RUN apt-get update && apt-get -y install libpq-dev gcc postgresql-client +FROM python:3.12 +# Update Pip +RUN python -m pip install --upgrade pip # Copy Python requirements COPY assets/requirements.txt / # Install Python requirements diff --git a/deployment/.gitignore b/deployment/.gitignore deleted file mode 100644 index 9a6a57564..000000000 --- a/deployment/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.serverless -node_modules -package-lock.json -package.json diff --git a/deployment/serverless.yml b/deployment/serverless.yml deleted file mode 100644 index f892d5218..000000000 --- a/deployment/serverless.yml +++ /dev/null @@ -1,217 +0,0 @@ -# For documentation see here - https://www.serverless.com/framework/docs/providers/aws/guide/serverless.yml -service: uec-dos-integration -frameworkVersion: '3' -configValidationMode: error - -provider: - name: aws - lambdaHashingVersion: 20201221 - architecture: x86_64 - region: ${env:AWS_REGION} - versionFunctions: false - deploymentBucket: - blockPublicAccess: true - skipPolicySetup: true - stackTags: - Environment: ${env:ENVIRONMENT} - PROFILE: ${env:PROFILE} - Product: ${env:PROJECT_ID} - Programme: ${env:PROGRAMME} - Service: ${env:PROJECT_ID} - tags: - Environment: ${env:ENVIRONMENT} - PROFILE: ${env:PROFILE} - Product: ${env:PROJECT_ID} - Programme: ${env:PROGRAMME} - Service: ${env:PROJECT_ID} - environment: - PROFILE: ${env:PROFILE} - ENV: ${env:ENVIRONMENT} - POWERTOOLS_SERVICE_NAME: ${env:PROGRAMME}-${env:TEAM_ID}-${sls:stage} - POWERTOOLS_TRACER_CAPTURE_RESPONSE: true - POWERTOOLS_TRACER_CAPTURE_ERROR: true - POWERTOOLS_TRACE_MIDDLEWARES: true - LOG_LEVEL: ${env:LOG_LEVEL} - BUILD_TIMESTAMP: ${env:VERSION} - IMAGE_VERSION: ${env:VERSION} - logs: - restApi: - format: '{"requestTime":"$context.requestTime","requestId":"$context.requestId","httpMethod":"$context.httpMethod","path":"$context.path","resourcePath":"$context.resourcePath","status":"$context.status","responseLatency":"$context.responseLatency","xrayTraceId":"$context.xrayTraceId","integrationRequestId":"$context.integration.requestId","functionResponseStatus":"$context.integration.status","integrationLatency":"$context.integration.latency","integrationServiceStatus":"$context.integration.integrationStatus","ip":"$context.identity.sourceIp","userAgent":"$context.identity.userAgent"}' - tracing: - lambda: true - apiGateway: true - apiGateway: - usagePlan: - throttle: - rateLimit: 3 - burstLimit: 5 - endpointType: regional - - -custom: - vpcDiscovery: - # Documentation - https://www.serverless.com/plugins/serverless-vpc-discovery - vpcName: '${env:AWS_VPC_NAME}' - subnets: - - tagKey: Name - tagValues: - - '${env:AWS_VPC_NAME}-private-${env:AWS_REGION}a' - - '${env:AWS_VPC_NAME}-private-${env:AWS_REGION}b' - - '${env:AWS_VPC_NAME}-private-${env:AWS_REGION}c' - securityGroups: - - tagKey: Name - tagValues: - - '${env:TF_VAR_lambda_security_group_name}' - -plugins: - - serverless-vpc-discovery -functions: - orchestrator: - image: ${env:DOCKER_REGISTRY}/orchestrator:${env:VERSION} - architecture: x86_64 - name: ${env:PROJECT_ID}-${sls:stage}-orchestrator - description: ${sls:stage} Orchestrator - memorySize: 128 - timeout: 295 # 4 minutes and 55 seconds (should mean max 5 second gap every 5 mins?) - role: arn:aws:iam::${env:AWS_ACCOUNT_ID}:role/${env:TF_VAR_orchestrator_role_name} - maximumRetryAttempts: 0 #??? - reservedConcurrency: 1 # ensure only one orchestrator running at a time - events: - - schedule: rate(1 minute) - tags: - PYTHON_VERSION: ${env:SERVERLESS_PYTHON_VERSION_TAG} - environment: - RUN_FOR: 280 - SLEEP_FOR_WHEN_OPEN: 10 - CIRCUIT: DOS_API_GATEWAY - EVENT_SENDER_FUNCTION_NAME: ${env:PROJECT_ID}-${sls:stage}-event-sender - CR_QUEUE_URL: ${env:cr_fifo_queue_url} - DOS_TRANSACTIONS_PER_SECOND: ${env:DOS_TRANSACTIONS_PER_SECOND} - CHANGE_EVENTS_TABLE_NAME: ${env:TF_VAR_change_events_table_name} - - event-processor: - image: ${env:DOCKER_REGISTRY}/event-processor:${env:VERSION} - architecture: x86_64 - name: ${env:PROJECT_ID}-${sls:stage}-event-processor - description: ${sls:stage} Event Processor lambda - memorySize: 128 - timeout: 10 - role: arn:aws:iam::${env:AWS_ACCOUNT_ID}:role/${env:TF_VAR_event_processor_role_name} - maximumRetryAttempts: 0 - tags: - PYTHON_VERSION: ${env:SERVERLESS_PYTHON_VERSION_TAG} - environment: - DB_SERVER: ${env:DB_SERVER} - DB_PORT: ${env:DB_PORT} - DB_NAME: ${env:DB_NAME} - DB_SCHEMA: ${env:DB_SCHEMA} - DB_USER_NAME: ${env:DB_USER_NAME} - DB_SECRET_NAME: ${env:DB_SECRET_NAME} - DB_SECRET_KEY: ${env:DB_SECRET_KEY} - CHANGE_EVENTS_TABLE_NAME: ${env:TF_VAR_change_events_table_name} - CR_QUEUE_URL: ${env:cr_fifo_queue_url} - - fifo-dlq-handler: - image: ${env:DOCKER_REGISTRY}/fifo-dlq-handler:${env:VERSION} - architecture: x86_64 - name: ${env:PROJECT_ID}-${sls:stage}-fifo-dlq-handler - description: ${sls:stage} FIFO DLQ Handler lambda - memorySize: 128 - timeout: 30 - role: arn:aws:iam::${env:AWS_ACCOUNT_ID}:role/${env:TF_VAR_fifo_dlq_handler_role_name} - tags: - PYTHON_VERSION: ${env:SERVERLESS_PYTHON_VERSION_TAG} - environment: - CHANGE_EVENTS_TABLE_NAME: ${env:TF_VAR_change_events_table_name} - maximumRetryAttempts: 0 - - event-sender: - image: ${env:DOCKER_REGISTRY}/event-sender:${env:VERSION} - architecture: x86_64 - name: ${env:PROJECT_ID}-${sls:stage}-event-sender - description: ${sls:stage} Event Sender lambda - memorySize: 512 - timeout: 10 - role: arn:aws:iam::${env:AWS_ACCOUNT_ID}:role/${env:TF_VAR_event_sender_role_name} - tags: - PYTHON_VERSION: ${env:SERVERLESS_PYTHON_VERSION_TAG} - environment: - DOS_API_GATEWAY_SECRETS: ${env:DOS_API_GATEWAY_SECRETS} - DOS_API_GATEWAY_USERNAME_KEY: ${env:DOS_API_GATEWAY_USERNAME_KEY} - DOS_API_GATEWAY_PASSWORD_KEY: ${env:DOS_API_GATEWAY_PASSWORD_KEY} - DOS_API_GATEWAY_REQUEST_TIMEOUT: ${env:DOS_API_GATEWAY_REQUEST_TIMEOUT} - DOS_API_GATEWAY_URL: ${env:DOS_API_GATEWAY_URL} - CR_QUEUE_URL: ${env:cr_fifo_queue_url} - CR_DLQ_URL: ${env:cr_dlq_queue_url} - CIRCUIT: DOS_API_GATEWAY - CHANGE_EVENTS_TABLE_NAME: ${env:TF_VAR_change_events_table_name} - maximumRetryAttempts: 0 - - slack-messenger: - image: ${env:DOCKER_REGISTRY}/slack-messenger:${env:VERSION} - architecture: x86_64 - name: ${env:PROJECT_ID}-${sls:stage}-slack-messenger - description: ${sls:stage} Slack Messenger lambda - memorySize: 128 - timeout: 10 - role: arn:aws:iam::${env:AWS_ACCOUNT_ID}:role/${env:TF_VAR_slack_messenger_role_name} - tags: - PYTHON_VERSION: ${env:SERVERLESS_PYTHON_VERSION_TAG} - environment: - SLACK_WEBHOOK_URL: ${env:SLACK_WEBHOOK_URL} - SLACK_ALERT_CHANNEL: ${env:SLACK_ALERT_CHANNEL} - events: - - sns: - arn: arn:aws:sns:${env:AWS_REGION}:${env:AWS_ACCOUNT_ID}:${env:TF_VAR_sns_topic_app_alerts_for_slack} - - cr-fifo-dlq-handler: - image: ${env:DOCKER_REGISTRY}/cr-fifo-dlq-handler:${env:VERSION} - architecture: x86_64 - name: ${env:PROJECT_ID}-${sls:stage}-cr-fifo-dlq-handler - description: ${sls:stage} cr-fifo DLQ Handler lambda - memorySize: 128 - timeout: 30 - role: arn:aws:iam::${env:AWS_ACCOUNT_ID}:role/${env:TF_VAR_cr_fifo_dlq_handler_role_name} - tags: - PYTHON_VERSION: ${env:SERVERLESS_PYTHON_VERSION_TAG} - maximumRetryAttempts: 0 - - event-replay: - image: ${env:DOCKER_REGISTRY}/event-replay:${env:VERSION} - architecture: x86_64 - name: ${env:PROJECT_ID}-${sls:stage}-event-replay - description: ${sls:stage} Event Replay lambda - memorySize: 128 - timeout: 30 - role: arn:aws:iam::${env:AWS_ACCOUNT_ID}:role/${env:TF_VAR_event_replay_role_name} - tags: - PYTHON_VERSION: ${env:SERVERLESS_PYTHON_VERSION_TAG} - environment: - CHANGE_EVENTS_TABLE_NAME: ${env:TF_VAR_change_events_table_name} - FIFO_SQS_NAME: ${env:TF_VAR_fifo_queue_name} - maximumRetryAttempts: 0 - - test-db-checker-handler: - image: ${env:DOCKER_REGISTRY}/test-db-checker-handler:${env:VERSION} - architecture: x86_64 - name: ${env:PROJECT_ID}-${sls:stage}-test-db-checker-handler - description: ${sls:stage} Test DB Checker Handler lambda - memorySize: 128 - timeout: 30 - role: arn:aws:iam::${env:AWS_ACCOUNT_ID}:role/${env:TF_VAR_test_db_checker_handler_role_name} - tags: - PYTHON_VERSION: ${env:SERVERLESS_PYTHON_VERSION_TAG} - environment: - DB_SERVER: ${env:DB_SERVER} - DB_PORT: ${env:DB_PORT} - DB_NAME: ${env:DB_NAME} - DB_SCHEMA: ${env:DB_SCHEMA} - DB_USER_NAME: ${env:DB_USER_NAME} - DB_SECRET_NAME: ${env:DB_SECRET_NAME} - DB_SECRET_KEY: ${env:DB_SECRET_KEY} - maximumRetryAttempts: 0 - -resources: - Conditions: - CreateNonProdResources: - !Not [!Equals ["${sls:stage}", live]] diff --git a/documentation/CONTRIBUTING.md b/documentation/Contributing.md similarity index 92% rename from documentation/CONTRIBUTING.md rename to documentation/Contributing.md index 8a0facc0e..b177f4519 100644 --- a/documentation/CONTRIBUTING.md +++ b/documentation/Contributing.md @@ -132,13 +132,13 @@ Upload the public key to your GitHub and GitLab accounts using the links below. Principles to follow - A direct merge to the canonical branch is not allowed and can only be done by creating a pull request (merge request) -- If not stated otherwise the only long-lived branch is master, i.e. canonical branch -- Any new branch should be created from master +- If not stated otherwise the only long-lived branch is main, i.e. canonical branch +- Any new branch should be created from main - The preferred short-lived branch name format is `task/JIRA-XXX_Descriptive_branch_name` - The preferred hotfix branch name format is `bugfix/JIRA-XXX_Descriptive_branch_name` - All commits must be cryptographically signed - Commits should be made often and pushed to the remote -- Use rebase to get the latest commits from the master while working with a short-lived or a bugfix branch +- Use rebase to get the latest commits from the main while working with a short-lived or a bugfix branch - Squash commits when appropriate - Merge commits are not allowed @@ -174,7 +174,7 @@ Contributing to an already existing branch git commit -S -m "Meaningful description of change" git push -Rebasing a branch onto master +Rebasing a branch onto main git checkout task/JIRA-XXX_Descriptive_branch_name git rebase -i HEAD~X # Squash X number of commits, all into one @@ -182,19 +182,19 @@ Rebasing a branch onto master # On the following screen replace pre-inserted comments by a single summary git push --force-with-lease - git checkout master + git checkout main git pull git checkout task/JIRA-XXX_Descriptive_branch_name - git rebase master + git rebase main # Resolve conflicts git add . git rebase --continue git push --force-with-lease -Merging a branch to master - this should be done only in an exceptional circumstance as the proper process is to raise an MR +Merging a branch to main - this should be done only in an exceptional circumstance as the proper process is to raise an MR - git checkout master - git pull --prune # Make sure master is up-to-date + git checkout main + git pull --prune # Make sure main is up-to-date git checkout task/JIRA-XXX_Descriptive_branch_name git pull # Make sure the task branch is up-to-date @@ -202,10 +202,10 @@ Merging a branch to master - this should be done only in an exceptional circumst # When prompted change commit type to `squash` for all the commits except the top one # On the following screen replace pre-inserted comments by a single summary - git rebase master # Rebase the task branch on top of master - git checkout master # Switch to master branch + git rebase main # Rebase the task branch on top of main + git checkout main # Switch to main branch git merge -ff task/JIRA-XXX_Descriptive_branch_name # Fast-forward merge - git push # Push master to remote + git push # Push main to remote git push -d origin task/JIRA-XXX_Descriptive_branch_name # Remove remote branch git branch -d task/JIRA-XXX_Descriptive_branch_name # Remove local branch @@ -252,7 +252,7 @@ Git hooks are located in `build/automation/etc/githooks/scripts` and executed au ### Git tags -Aim at driving more complex deployment workflows by tags with an exception of the master branch where the continuous deployment to a development environment should be enabled by default. +Aim at driving more complex deployment workflows by tags with an exception of the main branch where the continuous deployment to a development environment should be enabled by default. ## Pull request (merge request) @@ -260,7 +260,7 @@ Aim at driving more complex deployment workflows by tags with an exception of th - Ensure all commits will be squashed and the source branch will be removed once the request is accepted - Notify the team on Slack to give your colleagues opportunity to review changes and share the knowledge - If the change has not been pair or mob programmed it must follow the code review process and be approved by at least one peer, all discussions must be resolved -- A merge to master must be squashed and rebased on top, preserving the list of all commit messages +- A merge to main must be squashed and rebased on top, preserving the list of all commit messages ## Code review diff --git a/documentation/ONBOARDING.md b/documentation/Onboarding.md similarity index 75% rename from documentation/ONBOARDING.md rename to documentation/Onboarding.md index 3a3e6195d..7c34060a3 100644 --- a/documentation/ONBOARDING.md +++ b/documentation/Onboarding.md @@ -23,27 +23,28 @@ This is a generic onboarding template that can be used by any team to help to br ### Corporate Accounts -- Access to the NHSmail account, which is the prerequisite to all the others -- VDI to access Cherwell +- Give access to the NHSmail account which is the prerequisite to all the others - Microsoft Teams - DoS, SF, PU, Tech Strategy -- Slack - A2SI and Texas workspaces +- Slack - A2SI and Texas (`general` channel) workspaces - [JIRA](https://nhsd-jira.digital.nhs.uk/) - [Confluence](https://nhsd-confluence.digital.nhs.uk/) +- Raise [JIRA](https://nhsd-jira.digital.nhs.uk/secure/RapidBoard.jspa?rapidView=1875) ticket with the Texas team for a new starter ### Technical Setup -- macOS [configuration](https://github.com/nhsd-exeter/make-devops/blob/master/build/automation/lib/macos/README.md) +- Ensure your macOS is [configured](https://github.com/nhsd-exeter/make-devops/blob/master/build/automation/lib/macos/README.md) +- Get the AWS Console and command-line access (MFA must be enabled) - CPaaS Texas VPN access -- [GitLab](https://gitlab.mgmt.texasplatform.uk/) +- [GitHub](https://github.com/nhsd-exeter) & [GitLab](https://gitlab.mgmt.texasplatform.uk/) - [Jenkins](https://jenkins.mgmt.texasplatform.uk/) +- [SonarCloud](https://sonarcloud.io/organizations/nhsd-exeter/) & [SonarQube](https://sonarqube.mgmt.texasplatform.uk/) - [Instana](https://instana-server.mgmt.texasplatform.uk/) -- [GitHub](https://github.com/nhsd-exeter) - [Splunk](https://nhsdigital.splunkcloud.com/) -- AWS - ensure MFA is enabled -- Git projects -- Documentation +- Clone relevant git projects +- read the documentation - [Make DevOps](https://github.com/nhsd-exeter/make-devops) - [Software Engineering Quality Framework](https://github.com/NHSDigital/software-engineering-quality-framework) + - [Delivery Framework](https://github.com/robdale/delivery-framework) ### Organisational Information @@ -55,15 +56,15 @@ This is a generic onboarding template that can be used by any team to help to br ### Before joining -- Get a MacBook, minimum Intel Core i5 4-core processor and 16GM memory -- Install the latest version of the macOS operating system and all the updates unless there are known compatibility issues, usually it is a good practice to wait a couple of weeks after a new major version release +- Get a MacBook, minimum Intel Core i5 4-core processor (or Apple M1) and 16GM memory +- Install the latest version of the macOS operating system and all the updates unless there are known compatibility issues, usually it is a good practice to wait a couple of weeks after a new major version release of the operating system - Encrypt the disk storage - Request the NHSmail account, usually this will be done by a Delivery Manager ### Day 1 and 2 - Arrange a call with an ICT representative to set up the corporate accounts -- Buddy up new starter with last person to get development environment up and running. Make sure they know it’s totally ok to ask all kinds of questions. +- Buddy up new starter with a colleague to get development environment up and running. Make sure they know it’s totally ok to ask all kinds of questions. - The new engineer updates development environment documentation if it’s out of date or confusing. Therefore, they are contributing straight away and the documentation is better as a result. ### Day 3 to 5 diff --git a/documentation/TODO.md b/documentation/Todo.md similarity index 81% rename from documentation/TODO.md rename to documentation/Todo.md index 5791bc2dc..d9110d677 100644 --- a/documentation/TODO.md +++ b/documentation/Todo.md @@ -10,10 +10,12 @@ - [Architecture](#architecture) - [Infrastructure](#infrastructure) - [Security](#security) + - [Pipelines](#pipelines) + - [CloudWatch](#cloudwatch) List here all the technical tasks for prioritisation that need to be discussed with the team but are not ready yet to be placed on the backlog. This should form a holistic view of the state of the project and show the direction for incremental development and refactoring of certain areas of the software listed below. The idea behind this list is to ensure backlog hygiene and that it only consists of stories that can be completed within the next two sprints. Therefore, the focus can be changed dynamically depending on the business need. -This document must be discussed regularly with the Tech Lead and considered in the context of the [Engineering Quality Checks](https://github.com/NHSDigital/software-engineering-quality-framework/blob/master/quality-checks.md) provided as a guidance by NHS Digital Product Development directorate. +This document must be discussed regularly with the Tech Lead and considered in the context of the [Engineering Quality Checks](https://github.com/NHSDigital/software-engineering-quality-framework/blob/main/quality-checks.md) provided as a guidance by NHS Digital Product Development directorate. ## Regular tasks @@ -39,3 +41,13 @@ This document must be discussed regularly with the Tech Lead and considered in t ### Security - TODO + +### Pipelines + +- [ ] Automate Smoke Tests +- [ ] Automate Rollback Tests +- [ ] Automate Rollback in Prod + +### CloudWatch + +- [] Rename CloudWatch Queries/Alarms after release 8 diff --git a/documentation/adr/001-High-level_design_and_tech_stack.md b/documentation/adr/001-High-level_design_and_tech_stack.md index fc43678f8..648a673b4 100644 --- a/documentation/adr/001-High-level_design_and_tech_stack.md +++ b/documentation/adr/001-High-level_design_and_tech_stack.md @@ -4,13 +4,13 @@ Option selection report - Technology for centralized messaging broker -* Date: 2021/12/10 -* Status: Decided -* Deciders: The DoS Integration team, the DoS Tech Strategy team, Edward Sparkes +* Date: **16/01/2022** +* Status: **Decided** +* Deciders: **The DoS Integration team, the DoS Tech Strategy team, Edward Sparkes** ## Context -DI is a greenfiled integration project enabling the changes made by service owners such as Pharmacies to their contact information and opening times oN NHS.uk to be reflected in DoS to ensure consumers of DoS have up to date and accurate information about UEC services. +DI is a greenfield integration project enabling the changes made by service owners such as Pharmacies to their contact information and opening times on NHS.uk to be reflected in DoS to ensure consumers of DoS have up to date and accurate information about UEC services. It replaces Profile updater which provided a separate UI for service providers to update data for DoS. The new solution ensures service providers only need to make the changes in 1 place. As an interim solution Profile updater was updated to take a CSV feed from NSH Uk once a day and passed this information to DoS. This solution has its own limitations like data won't reflect to the end user immediately. The proposed solution is based on Messaging Queue technology (publish/subscribe). This paper provides the available options and our recommendations. we are evaluating the options available as "Cloud native solutions/services" which are as follows @@ -33,13 +33,13 @@ High level requirements: #### Option 1 - Use AWS Managed Services -This solution utilises SQS and EventBridge to handle the asynchronous orchestration of change events from NHS Uk through to delivery of change requests to DoS. +This solution utilises SQS and EventBridge to handle the asynchronous orchestration of change events from NHS UK through to delivery of change requests to DoS. Pros: * A fully managed service in AWS * Easy to configure -* Good experience of terraform in the team for provisioning the services +* Good experience of Terraform in the team for provisioning the services * Faster time to value - we can start using this with very little effort * Slightly cheaper infrastructure costs compared to MSK (to be confirmed based on volumes of messages) * Better fit for non functional requirements especially around throttling requests into DoS API Gateway @@ -76,15 +76,15 @@ Cons: Based on the non-functional requirements, the AWS native solution is a better fit to the overall solution. -The diagram below shows the physical architecture of the solution. The blue numbering shows happy path, yellow numbering shows the known issues routes and the red numbering shows exception routes. +The diagram below shows the physical architecture of the solution. -![DoS Infrastructure](../diagrams/DoSIntArchitecture.png "Dos Infrastructure") +![DoS Infrastructure](../diagrams/DoS%20Integration-Infrastructure.drawio.png "Dos Infrastructure") ### The Happy Path -1. Service owners make changes to their service through the NHS Uk website. They can make the changes through a Web user interface or through an API. The API will likely be used by large pharmacy groups such as LLoyds and Boots that manage thousands of pharmacies. +1. Service owners make changes to their service through the NHS UK website. They can make the changes through a Web user interface or through an API. The API will likely be used by large pharmacy groups such as LLoyds and Boots that manage thousands of pharmacies. Address and organisation name changes cannot be made via the NHS UK website or API and will come via NHS UK from a weekly BSA file uploaded into NHS UK. -2. NHS Uk puts full service record including updated and non updated fields onto two queues. One queue is used internally to update search indexes the other is for DoS integration, there are two separate queues to allow the two consumers to diverge in the messages they need to receive. +2. NHS UK updates their record and sends a full service record including updated and non updated fields onto two queues. One queue is used internally to update search indexes the other is for DoS integration, there are two separate queues to allow the two consumers to diverge in the messages they need to receive. 3. An Azure function is triggered when new messages appear on DoS integration queue. @@ -98,35 +98,39 @@ The diagram below shows the physical architecture of the solution. The blue numb 8. The Lambda function will check DynamoDB for the latest sequence number processed for the message ODS code, if there have been no previous messages or the sequence number is greater than the last processed then the Lambda continues execution, if not the message is discarded and logged. -9. The Lambda then retrieves services from the DoS database and compares them to the message to see what changes are required. The Lambda connects to the DoS Database Replica using RDS Proxy which helps manage and reuse database connections. +9. The Lambda then retrieves services from the DoS database and compares them to the message to see what changes are required. The Lambda connects to a dedicated read replica of the DoS Database, connections are cleared up after use. -10. If changes have been identified the changes are pushed to EventBridge, if not the message is discarded +10. If changes have been identified they are sent to a separate queue for forwarding to DoS. -11. Once the changes have been sent to EventBridge the sequence number is updated in the DynamoDB. +11. An orchestrator function is triggered by EventBridge to run every minute with concurrency of 1. It reads messages from the queue and sends them to the event sender lambda via asynchronous invocation at a rate of 3 / second. This rate is configurable in the lambda environment variables. -12. EventBridge is configured to push messages to DoS API gateway using a feature called Api Destinations. Api Destinations allows you to configure the rate at which messages are sent to the third part API (in this case DoS API Gateway). +12. The event sender forwards the change request to the DoS change request API, and remove the message from the queue. ### Known issues -There are a number of scenarios that could occur that will not result in a change in DoS and that identify a discrepancy between the two systems. In order to rectify or at the least understand these discrepancies they must be captures. The following known scenarios include. +There are a number of scenarios that could occur that will not result in a change in DoS and that identify a discrepancy between the two systems. In order to rectify or at the least understand these discrepancies they must be captured. The following known scenarios include. * ODS code not in DoS * Postcode not in DoS or in DoS without lat/lon and easting/northing -* Service marked as Hidden or Closed from NHS Uk +* Invalid opening times, including overlaps +* Service marked as Hidden or Closed from NHS UK -In each of these scenarios a specific log record will be written. All logs are shipped to Splunk via Kinesis Firehose. Reports will be written in Splunk to look for these specific scenarios and notifications sent to relevant teams for investigation. +In each of these scenarios a specific log record will be written. All logs are shipped to Splunk via Kinesis Firehose. Reports will be written in Splunk to look for these specific scenarios and notifications sent to relevant teams for investigation. All reports are saved under the `Texas UEC Profile Updater` App in Splunk and all logs are in the `cw_logs_uec_dos_int_prod` and `cw_logs_uec_dos_int_nonprod` indexes For these known scenarios the message is removed from the queue as it will not pass until the underlying issue has been rectified. Once the issue is resolved the message can be replayed at a later date as it has been saved to DynamoDB. ### Exception routes 1. Processing lambda fails or cant meet demand from the SQS queue. -If the lambda fails to process the message for some unknown or intermittent reason, perhaps including a database connection issue, the message will be requeued and retried. The number of times it is retried and the time between each retry can be configured. This retry mechanism will make resilient to intermittent issues. If it still fails after retry attempts have been exhausted it will be moved to a dead letter queue. This will allow other messages for the same ODS code to come through and hopefully succeed. +If the lambda fails to process the message for some unknown or intermittent reason, perhaps including a database connection issue, the message will be re-queued and retried. The number of times it is retried and the time between each retry can be configured. This retry mechanism will make resilient to intermittent issues. If it still fails after retry attempts have been exhausted it will be moved to a dead letter queue. This will allow other messages for the same ODS code to come through and hopefully succeed. 1.1. New messages on the DLQ will trigger a Lambda function 1.2. The Lambda function will attempt to write the message to Dynamo so we will keep a record of it. 1.3. And fire a notification to inform someone that the message couldn't be processed. -2. DoS API Gateway returns 429 or 5xx error. -If the DoS API Gateway returns a 429 or a 5xx error the message will remain on the EventBridge and be retried upto 185 times over 24 hours. After 24 hours if it has still not succeeded it will go to the DLQ. + +2. DoS API Gateway returns a potentially intermittent error (429 or 5xx). +If the DoS API Gateway returns a 429 or a 5xx error the message, the event sender will update a record in the Dynamo DB to say there is an issue with the downstream system, it will also not remove the message from the queue so it can be retried when the downstream system is back up. The orchestrator checks this status before processing next batch of messages and if it finds there is an error, it pauses execution for a configured time and then sends a health check message to the event sender which is forwarded to the DoS API. If the healthcheck returns good then the Event Sender updates the Dynamo DB to say all is well and the orchestrator resumes sending change requests. If it is still down the orchestrator will repeat pausing and sending health checks until the service resumes. This solution ensures the system is resilient to temporary downstream outages. Messages will remain on the queue for up to 14 days or until the queue is full, which provides ample buffer for issues with the DoS Change request API to be resolved and processing continue without any intervention on the DI side. + +![DoS Infrastructure](../diagrams/DoS%20Integration-Circuit%20Breaker.drawio.png "Dos Infrastructure") diff --git a/documentation/adr/002-Reporting-approach.md b/documentation/adr/002-Reporting-approach.md index 4651c31fd..77d14786d 100644 --- a/documentation/adr/002-Reporting-approach.md +++ b/documentation/adr/002-Reporting-approach.md @@ -51,7 +51,7 @@ This option is to use a range of AWS services to store logs, report, email and a Pros: * Control over infrastructure and reporting (we create and control what we require) -* We can use python to create whatever kinds of reports we want CSV, PDF and graphs. All report data/format is within DoS Integration team's control. +* We can use Python to create whatever kinds of reports we want CSV, PDF and graphs. All report data/format is within DoS Integration team's control. * 100% of logs in storage mechanism. (Less chance of log not reaching log storage) * Keep logs for longer than two years @@ -60,7 +60,7 @@ Cons: * Need a pipeline to deploy reporting infrastructure (This pipeline would increase development time) * Additional Texas permissions required to trigger reports (Texas may not give us such permission) * A lengthy process to create a report. A developer is required to create each report and must be tested. -* Requires multiple AWS Services that must be be put into infrastructure as code. Which must be maintained. +* Requires multiple AWS Services that must be put into infrastructure as code. Which must be maintained. * Reporting solution must be paid for by our team. Money taken out of our budget ## Decision diff --git a/documentation/adr/004-DoS-connection-architecture.md b/documentation/adr/004-DoS-connection-architecture.md new file mode 100644 index 000000000..4a07e168c --- /dev/null +++ b/documentation/adr/004-DoS-connection-architecture.md @@ -0,0 +1,66 @@ +# ADR-004: DoS Connection Architecture + +## Overview + +DoS Connection Architecture - How to improve throughput of the application? / How to remove the DoS Changes queue (events in changes table in a pending state)? + +* Date: 2022/07/22 +* Status: Decided +* Deciders: The DI development team / DoS team + +## Context + +A large of number of changes events caused an issue where changes were backed up with the DoS DB and then were not processed in a timely manner. This solution is to remove the DoS Changes queue (events in changes table in a pending state) so that it can never happen again. + +High level requirements: + +* To process a high number of changes events in a timely manner +* Changes events are process efficiently to reduce load/requests on the DoS DB + +### Detailed analysis of the options + +#### Option 1 - Tactical Solution - Improve DoS API Gateway + +This option is to automatically approve the changes when they are received in the DoS Change Endpoint. Therefore the change queue is not required and won't be limiting the number of changes that can be processed. + +Pros: + +* No need to maintain a change queue +* Modification of a current system is easier and faster than building a new system + +Cons: + +* Maintaining a legacy system that will be replaced by a new system at some point to remove PHP from tech stack +* DI Team doesn't have knowledge of the DoS API Gateway, Changes Endpoint and the Save Endpoint. +* Lack of skills within the DI team to improve/fix the DoS API Gateway + +### Option 2 - Strategic Solution - Connect directly to the DoS database + +This option is to connect directly to the DoS database and process the changes directly. + +Pros: + +* No need to maintain a change queue +* New system will be able to process changes faster and more efficiently than the legacy Change Endpoint +* Understanding built when creating the new system will be helpful to the DI team +* Easier to upgrade the new system to include new data items + +Cons: + +* DI Team doesn't have much knowledge of the DoS database +* DI Team doesn't have knowledge of the DoS API Gateway, Changes Endpoint and the Save Endpoint so a bit of work is required to get the new system to connect to the DoS database and understand the data structures +* DI is tighter coupled to the DoS database. + +## Decision + +**The decision was to go with option 2 using the strategic solution** as it was the most compatible with the project and team. The benefits of option 1 were the development time would be shorter and easier to develop but after an experiment it uncovered some issues with the way the change endpoint behaved. So it was decided that it was more beneficial to build option 2 rather than fix option 1. + +The team were happy with this decision as more of the DI process is now under DI Team's control and they will be more confident in the new system. + +## Glossary + +DoS API Gateway - The DoS API Gateway is the main entry point for the DoS integration project. Nginx is used to proxy the requests to the DoS API. + +Changes Endpoint - The Changes Endpoint is the API that the DoS API Gateway proxies to. + +Note: DoS API Gateway and Changes Endpoint may be used inter-changeably in some cases. As the process is colloquially known as the "DoS API Gateway" despite being a multilayered endpoints. diff --git a/documentation/adr/005-Email-sending-architecture.md b/documentation/adr/005-Email-sending-architecture.md new file mode 100644 index 000000000..af7437f13 --- /dev/null +++ b/documentation/adr/005-Email-sending-architecture.md @@ -0,0 +1,55 @@ +# ADR-005: Email Sending Architecture + +## Overview + +Email Sending Architecture - How can DI send emails to users in a timely, asynchronous and reliable manner? + +* Date: 2022/09/01 +* Status: Decided +* Deciders: Adi & Jack + +## Context + +Under certain circumstances DI needs to be able to email users to notify them of events. In this case it is when a DoS user had pending changes which DI has rejected. + +High level requirements: + +* To process send emails in a timely manner +* Emails are sent asynchronously to rest of the application +* Emails aren't sent in non-production environments + +### Detailed analysis of the options + +#### Option 1 - Use NHS Mail (SMTP connection) + +This option is to use NHS Mail to send emails. This is the current method used by the DoS application. + +Pros: + +* We need a NHS Email address anyway so why not use it to send emails +* No cost to DI to use NHS Mail + +Cons: + +* Requires a NHS Email address to be created with SMTP access which can often be a slow process +* Relies on NHS Mail working which is a Microsoft 365 service therefore Microsoft Azure is required to be available and working +* Need to change password for NHS Email address when it expires every year meaning so there will be temporary interruption in emails being sent between the password change and the new password being set in secrets manager + +### Option 2 - Use AWS SES (Boto3 Connection) + +This option is to use AWS SES to send emails. SES is a service provided by AWS which allows you to send emails which pretends to be from your own domain meaning that it doesn't rely on NHS Mail after it has been set up. + +Pros: + +* Reliable as built on AWS +* Password change won't cause downtime as it doesn't rely on NHS Mail +* Simulator mailbox can be used to test emails are being sent + +Cons: + +* A slight cost to DI to use AWS SES +* Difficult to set up to have the emails appear to be from the NHS domain + +## Decision + +**The decision was to go with option 2** as it is more reliable and doesn't rely on NHS Mail. Also the cost of using AWS SES is minimal but provides us with a better interface to send emails using boto3. The simulator mailbox is also a nice feature to have to test emails are being sent. diff --git a/documentation/adr/006-Message-Grouping-architecture.md b/documentation/adr/006-Message-Grouping-architecture.md new file mode 100644 index 000000000..c8b1bf91a --- /dev/null +++ b/documentation/adr/006-Message-Grouping-architecture.md @@ -0,0 +1,57 @@ +# ADR-006: Message Grouping Architecture + +## Overview + +Message Grouping Architecture - How can DI group change events into a single change event? So that only a single Change Event is sent to the DoS application. + +* Date: 2022/10/17 +* Status: Decided +* Deciders: Adi & Jack + +## Context + +NHS UK give DI multiple change events for each user session. This means DI receive multiple change events with the data changing incrementally between events. When a new cohort is introduced the first message received is the data is mostly incorrect with it being updated to the correct data in the following messages as the user progresses through the journey. + +High level requirements: + +* Change events are grouped into a single (latest) change event +* Most messages are grouped into a single change event within a reasonable time frame + +### Detailed analysis of the options + +#### Option 1 - SQS Message Deduplication for Grouping + +This option is to use SQS Message Deduplication to group messages into a single change event by deduplicating SQS messages within a delay queue. However this option is not possible as the deduplication only works for exactly 5 minutes. If we wanted to increase or decrease delay times messages would be lost or not grouped correctly. + +#### Option 2 - DynamoDB Lock for Grouping + +This option is to use DynamoDB Lock to group messages into a single change event by locking the odscode in DynamoDB. This lock would tell the message grouping lambda whether to send through the odscode to the delay queue. As the lock is released after the message is processed by the service matcher lambda the lock would be released and the next message for the odscode would be able to be processed. + +Steps: + +1. Change Event is received by the message grouping lambda +2. Change Event is added to message grouping dynamodb table +3. Check if odscode is locked in dynamodb (Message Grouping Lock table) + 1. If not locked: + 1. Add odscode is lock into DynamoDB (Message Grouping Lock table) + 2. Odscode is added to delay queue + 2. If locked: + 1. Odscode is not added to delay queue +4. Message is processed by service matcher lambda +5. Lock is released in DynamoDB (Message Grouping Lock table) +6. Latest change event for odscode is processed by service matcher lambda + +Pros: + +* Locking mechanism is reliable +* Locking time can be increased or decreased to suit the needs of the application +* Locking mechanism is scalable + +Cons: + +* Locking mechanism is complex +* Locking mechanism is not free (small cost for additional DynamoDB tables) + +## Decision + +**The decision was to go with option 2** as it is more flexible and scalable than option 1. This allows us to increase or decrease the locking time to suit the needs of the application such as if we wanted to increase the locking time to allow for more messages to be grouped into a single change event when a new cohort is introduced. Or decrease the locking time to allow for messages to be processed quicker such as in nonprod. diff --git a/documentation/adr/007-Live-Environments-Versioning.md b/documentation/adr/007-Live-Environments-Versioning.md new file mode 100644 index 000000000..94480b903 --- /dev/null +++ b/documentation/adr/007-Live-Environments-Versioning.md @@ -0,0 +1,36 @@ +# ADR-007: Live Environments Versioning + +## Overview + +Live Environments Versioning - How to version the live environment of the DI application? + +* Date: 2022/10/18 +* Status: Decided +* Deciders: Adi & Jack + +## Context + +How to version the live environment of the DI application? So that we can deploy changes to the live environment without affecting the current live version. + +High level requirements: + +* DI team must know which version of the application is live and in use at any given time +* Reports must be able to be run on any version of the application + +### Detailed analysis of the options + +#### Option 1 - Static Environments (Blue/Green) + +This option is to have two static environments (Blue/Green) that are used for the live environment. When a new version of the application is ready to be deployed to the live environment the current live environment is swapped with the new environment. This means that the current live environment is now the new environment and the new environment is now the live environment. This means either the blue or green environment is in use and would have to be tracked somewhere so new deployments are made to the other environment. + +With this way of working to upgrade an environment the IaC (Infrastructure as Code) must be run on an already deployed environment. + +#### Option 2 - Dynamic Environments (1.0, 2.0, 4.0) + +This option is to have multiple environments that can be used and swapped to if required. Such as the current Live version, rollback version and a version ready for the next release. The live version will also be documented but as version is a number it will be easier to remember which version is live. + +With this way of working to upgrade an environment the IaC (Infrastructure as Code) won't be run on existing environments meaning the code doesn't need to be backwards compatible. This reduces the complexity of the IaC and testing required. + +## Decision + +**The decision was to go with option 2** as it is more flexible and scalable than option 1. This allows us to have multiple environments that can be used and swapped to if required so we could scale to as many environments as required. By not having to run the IaC on existing environments it reduces deployment time and complexity of the IaC. diff --git a/documentation/adr/008-Live-Environments-Message-Routing.md b/documentation/adr/008-Live-Environments-Message-Routing.md new file mode 100644 index 000000000..3df4fae4a --- /dev/null +++ b/documentation/adr/008-Live-Environments-Message-Routing.md @@ -0,0 +1,31 @@ +# ADR-008: Live Environments Message Routing + +## Overview + +Live Environments Message Routing - How to select which environment a message is routed to? + +* Date: 2022/10/18 +* Status: Decided +* Deciders: Adi & Jack + +## Context + +How to select which environment a message is routed to? So that messages can be routed to the correct environment and withheld from the live environment during deployment. + +High level requirements: + +* Messages must be routed to the correct environment +* Messages must be withheld from the live environment during deployment +* Message routing must be configurable by Infrastructure as Code (IaC) + +### Detailed analysis of the options + +#### Option 1 - SQS Lambda Subscription + +This option is to have a Lambda function that is subscribed to the SQS queue. The lambda from the environment is live will be subscribed. + +This allows the subscription to be controlled by the IaC and the lambda function can be used to route the message to the correct environment or withhold it from the live environment during deployment. To withhold the message from the live environment there won't be a lambda function subscribed to the queue so the message will be held in the queue. + +## Decision + +We debated multiple minor options but each was unable to pause messages during deployment so **The decision was to go with option 1**. This allows us to have a lambda function that can be used to route the message to the correct environment or withhold it from the live environment during deployment. Therefore the shared resources can all be handled by Terraform with no manual intervention to pause messages during deployment or to route messages to the correct environment. diff --git a/documentation/adr/009-Live-Environments-Shared-Resources.md b/documentation/adr/009-Live-Environments-Shared-Resources.md new file mode 100644 index 000000000..acf73a300 --- /dev/null +++ b/documentation/adr/009-Live-Environments-Shared-Resources.md @@ -0,0 +1,30 @@ +# ADR-009: Live Environments Shared Resources + +## Overview + +Live Environments Shared Resources - How to share resources between live environments? + +* Date: 2022/10/18 +* Status: Decided +* Deciders: Adi & Jack + +## Context + +How to share resources between live environments? So that we can swap between environments without losing data. + +High level requirements: + +* Persistent data must be shared between environments +* Shared resources must be configurable by Infrastructure as Code (IaC) +* Shared resources must be accessible by all live environments + +### Detailed analysis of the options + +#### Option 1 - Shared Resources between environments + +This option is to have shared resources between environments. This means that the resources are created once and then used by all environments. So any resources that are already created will have to be migrated to a new terraform stack and state to allow it to be remove from blue/green environments. Shared resources that use KMS encryption will need to be migrated to a new shared KMS key and using Terraform outputs to allow the new KMS key to be used by the blue/green environments. + + +## Decision + +**The decision was to go with option 1** as it was the only feasible plan to ensure persistent data was retained between environments. This allows us to have shared resources between environments so we can swap between environments without losing data. diff --git a/documentation/adr/010-Improve-Application-Performance.md b/documentation/adr/010-Improve-Application-Performance.md new file mode 100644 index 000000000..be376bf2d --- /dev/null +++ b/documentation/adr/010-Improve-Application-Performance.md @@ -0,0 +1,60 @@ +# ADR-010: Improve Application Performance + +## Overview + +How to improve DoS Integration's application performance to allow for multi-cohorts + +* Date: 2023/04/12 +* Status: Decided +* Deciders: DoS Integration Development Team + +## Context + +How can DoS Integration's application throughput be increased to support multi-cohorts. While keeping services processed in FIFO order (per cohort). + +High level requirements: + +* Cohort data is in FIFO order +* Application can handle large spikes of traffic such as around bank holidays +* Costs don't radically increase +* Architecture can be easily changed when new cohorts are ready to be on-boarded + +### Constraints + +If DoS Integration's performance is improved it is likely that Profile Manager's (PM) side will be the bottleneck for DoS Integration. Which will have to be rectified by PM's development team. + +### Detailed analysis of the options + +#### Option 1 - Remove orchestrator + +This option is to remove the orchestrator and have the service matcher place the right queue for the service type which would then be pulled off by a service sync lambda for that specific cohort. The rate limiting for these service sync lambdas would be to use lambda max concurrency + +Pros: + +* DoS Integration's Performance can scale dramatically (1X - 1000X) +* Reduced code means less maintenance. Orchestrator is difficult to read and understand the message processing. +* No Additional Cost +* Easy to add new cohorts or add specific routes for DoS types + +Cons: + +* Slightly longer development time from option 1 + +#### Option 2 - Multiple orchestrators + +Option two is to scale the Orchestrators so that there would be multiple orchestrators per cohort. This means that to work out performance the orchestrators would have to be 6X to for a rate per second. 6 Per second is the current maximum performance for the Orchestrator lambda. + +Pros: + +* Easy to implement, fast to market time + +Cons: + +* Continue to use an anti pattern +* Rate per second isn't crucial as DB usage is now the limiting factor +* A cost increase to improve performance (Each orchestrator costs about $65 per year) +* More difficult to add new routes for different DoS service types. + +## Decision + +**The decision was to go with option 1** as it can radically improve performance while taking away technical debt, it's the best of both worlds. diff --git a/documentation/adr/011-Quality-Checking.md b/documentation/adr/011-Quality-Checking.md new file mode 100644 index 000000000..e1a6003b8 --- /dev/null +++ b/documentation/adr/011-Quality-Checking.md @@ -0,0 +1,50 @@ +# ADR-011: Quality Checking + +## Overview + +How to ensure that DoS data is checked for quality + +* Date: 2023/10/01 +* Status: Decided +* Deciders: DI Development Team + +## Context + +How can DoS Integration check the data integrity and quality of the DoS Database. + +High level requirements: + +* Minimize database usage +* Minimize message latency + +### Detailed analysis of the options + +#### Option 1 - Improve current adhoc checking + +This option is to improve the searching in the Service Matcher. + +Pros: + +* Quick to implement + +Cons: + +* Slows down message latency with each additional check + +#### Option 2 - Create new all at once database checking solution + +This option is to replace the DoS profiling checks in the Service Matcher with a new lambda that runs weekly to check the database. + +Pros: + +* Improves message latency by removing current checks +* New checks can be added without adding additional latency +* Easier to maintain as reporting is separate to other processes in the Service Matcher + +Cons: + +* Slows down deployment as an additional lambda must be built + +## Decision + +**The decision was to go with option 2** as it reduces the load of the DoS database and improves message latency. diff --git a/documentation/adr/012-Application-Metrics.md b/documentation/adr/012-Application-Metrics.md new file mode 100644 index 000000000..81b0d83a6 --- /dev/null +++ b/documentation/adr/012-Application-Metrics.md @@ -0,0 +1,68 @@ +# ADR-012: Review of Application Metrics and how metrics are generated + +## Overview + +How to generate metrics? - What is the best process?, Costs least and is easiest to maintain. + +* Date: 2023/11/15 +* Status: Decided +* Deciders: The DoS Integration development team + + +## Context + +The DoS Integration project currently uses a Python library called aws-embedded-metrics to create metrics using AWS's system for generating metrics by embedding them into logs. However, an alternative cloud native option is to use AWS CloudWatch Metric Filters. Is our current approach the best for creating AWS CloudWatch metrics? + +As well any decision can be used to inform any new applications that may be developed by this team in future. + +High level requirements + +* Generating Metrics should be cheap to create and store +* Generating Metrics should be maintainable +* CloudWatch Metrics work with Splunk + +### Detailed analysis of the options + +#### Option 1 - Maintain Metrics as Embedded Metrics + +This option is to remain with aws-embedded-metrics as the way for generating metrics. + +Pros: + +* Metrics are co-located next to logs + +Cons: + +* Embedded metrics use an untyped library requiring the any type to be used +* Difficult to understand metrics class due to no types +* Requires an additional Python library which must be install and imported +* Library doesn't currently have 3.12 wheels which breaks the docker build when set to run in Python 3.12 +* Library isn't well supported in comparison to aws-lambda-powertools which also have metric generating features. + +#### Option 2 - Migrate Metrics to CloudWatch Metric Filters + +This option is to replace the current Metric generation system with CloudWatch Metric Filters to generate the logs + +Pros: + +* CloudWatch Metric Filters are free + * Note: Metrics themselves still cost money +* Allows earlier migration to Python 3.12 +* Reducing application code increases performance of lambdas and application throughput + * Reduces Python code complexity including unit test complexity +* Less logs created + * Easier to analyse logs due to less clutter +* Doesn't require an additional Python library + +Note: Reducing logs and lambda duration cost from estimates appears to reduces cost negligibly + +Cons: + +* Metrics code isn't co-located with logs +* Metrics are defined by JSON matching, if the matching criteria no longer matches the log then no metric will be created + +## Decision + +**The decision was made to go with option 2** as it provides many benefits such as reducing code complexity with few downsides. On of the downsides mentioned in this discussion is the chance for the metrics to be incorrectly set after a future change is made to ensure this doesn't happen the mitigation will be to add a new key on each log which suggests that the log is being used by a CloudWatch Metric Filter to reduce the likelihood of a breaking change. + +As such we will now transition all generation of metrics to AWS CloudWatch Metric Filters and remove any related code to aws-embedded-metrics. diff --git a/documentation/chaos-engineering/scenario_1/ingest_change_event_lambda_error_rate_alert.png b/documentation/chaos-engineering/scenario_1/ingest_change_event_lambda_error_rate_alert.png new file mode 100644 index 000000000..7ccf6bfb7 Binary files /dev/null and b/documentation/chaos-engineering/scenario_1/ingest_change_event_lambda_error_rate_alert.png differ diff --git a/documentation/chaos-engineering/scenario_1/scenario_one.md b/documentation/chaos-engineering/scenario_1/scenario_one.md new file mode 100644 index 000000000..30cecbb4f --- /dev/null +++ b/documentation/chaos-engineering/scenario_1/scenario_one.md @@ -0,0 +1,48 @@ +# Ingest change event lambda issue + +## Table of contents + +- [Ingest change event lambda issue](#ingest-change-event-lambda-issue) + - [Table of contents](#table-of-contents) + - [Description](#description) + - [How did the development team discover the issue?](#how-did-the-development-team-discover-the-issue) + - [Steps to gain more information about the issue](#steps-to-gain-more-information-about-the-issue) + - [Application features to ensure data integrity](#application-features-to-ensure-data-integrity) + - [How to fix the issue](#how-to-fix-the-issue) + +## Description + +This is a breaking issue within the ingest change event lambda. + +Examples of an issue could be: + +- Incorrect environment variables +- Incorrect lambda permissions +- The lambda isn't able to save change events to the AWS DynamoDB database +- The lambda isn't able to send change events to the AWS SQS queue + +## How did the development team discover the issue? + +A slack alert arrived in the development team slack channel with the following message: +`Ingest Change Event Error Rate` + +![Ingest Change Event Lambda Error Rate Alert](./ingest_change_event_lambda_error_rate_alert.png) + +## Steps to gain more information about the issue + +- Check the CloudWatch Dashboard for the ingest change event lambda error rate +- Check the CloudWatch Logs for the ingest change event lambda +- Check the CloudWatch Log Insights Errors query, then look for the ingest change event lambda errors + +## Application features to ensure data integrity + +- The ingest change event lambda is idempotent, so change events can be retried without any issues +- Failed change events automatically are retried 4 times (5 times including original run), before being sent to the dead letter queue. This queue is subscribed by the change event DLQ handler lambda, which saves the change events to the AWS DynamoDB database. + - Once the issue is fixed, the change events can be reprocessed by the development team using the event replay lambda. + +## How to fix the issue + +1. Diagnose the issue using the steps preceding under the "Steps to gain more information about the issue" section +2. Fix the issue +3. Reprocess the failed change events using the event replay lambda +4. Monitor the ingest change event lambda error rate to ensure the issue has been resolved diff --git a/documentation/chaos-engineering/scenario_1/scenario_one_infrastructure.drawio.png b/documentation/chaos-engineering/scenario_1/scenario_one_infrastructure.drawio.png new file mode 100644 index 000000000..760c0c4c4 Binary files /dev/null and b/documentation/chaos-engineering/scenario_1/scenario_one_infrastructure.drawio.png differ diff --git a/documentation/chaos-engineering/scenario_2/scenario_two.md b/documentation/chaos-engineering/scenario_2/scenario_two.md new file mode 100644 index 000000000..c39b51ee2 --- /dev/null +++ b/documentation/chaos-engineering/scenario_2/scenario_two.md @@ -0,0 +1,50 @@ +# Service matcher lambda issue + +## Table of contents + +- [Service matcher lambda issue](#service-matcher-lambda-issue) + - [Table of contents](#table-of-contents) + - [Description](#description) + - [How did the development team discover the issue?](#how-did-the-development-team-discover-the-issue) + - [Steps to gain more information about the issue](#steps-to-gain-more-information-about-the-issue) + - [Application features to ensure data integrity](#application-features-to-ensure-data-integrity) + - [How to fix the issue](#how-to-fix-the-issue) + +## Description + +This is a breaking issue within the service matcher lambda. + +Examples of an issue could be: + +- Incorrect environment variables +- Incorrect lambda permissions +- Secrets manager secrets are incorrect or missing (Managed by DoS Integration DevOps Team and Core DoS Team) +- The lambda isn't able to connect to the AWS Aurora Postgres database (DoS) +- The lambda isn't able to save/receive change events to the AWS SQS queue + +## How did the development team discover the issue? + +A slack alert arrived in the development team slack channel with the following message: +`Service Matcher Error Rate` + +![Service Matcher Lambda Error Rate Alert](./service_matcher_lambda_error_rate_alert.png) + +## Steps to gain more information about the issue + +- Check the CloudWatch Dashboard for the service matcher error rate +- Check the CloudWatch Logs for the service matcher lambda +- Check the CloudWatch Log Insights Errors query, then look for the service matcher lambda errors + +## Application features to ensure data integrity + +- The service matcher lambda is idempotent, so change events can be retried without any issues +- Failed change events automatically are retried 4 times (5 times including original run), before being sent to the dead letter queue. This queue is subscribed by the change event DLQ handler lambda, which saves the change events to the AWS DynamoDB database. + - Once the issue is fixed, the change events can be reprocessed by the development team using the event replay lambda. +- Database connections use default timeouts to remove connections that are no longer in use + +## How to fix the issue + +1. Diagnose the issue using the steps preceding under the "Steps to gain more information about the issue" section +2. Fix the issue +3. Reprocess the failed change events using the event replay lambda +4. Monitor the service matcher lambda error rate to ensure the issue has been resolved diff --git a/documentation/chaos-engineering/scenario_2/scenario_two_infrastructure.drawio.png b/documentation/chaos-engineering/scenario_2/scenario_two_infrastructure.drawio.png new file mode 100644 index 000000000..877243c31 Binary files /dev/null and b/documentation/chaos-engineering/scenario_2/scenario_two_infrastructure.drawio.png differ diff --git a/documentation/chaos-engineering/scenario_2/service_matcher_lambda_error_rate_alert.png b/documentation/chaos-engineering/scenario_2/service_matcher_lambda_error_rate_alert.png new file mode 100644 index 000000000..40e6eb59f Binary files /dev/null and b/documentation/chaos-engineering/scenario_2/service_matcher_lambda_error_rate_alert.png differ diff --git a/documentation/chaos-engineering/scenario_3/scenario_three.md b/documentation/chaos-engineering/scenario_3/scenario_three.md new file mode 100644 index 000000000..5e4cdc83a --- /dev/null +++ b/documentation/chaos-engineering/scenario_3/scenario_three.md @@ -0,0 +1,52 @@ +# Service sync lambda issue + +## Table of contents + +- [Service sync lambda issue](#service-sync-lambda-issue) + - [Table of contents](#table-of-contents) + - [Description](#description) + - [How did the development team discover the issue?](#how-did-the-development-team-discover-the-issue) + - [Steps to gain more information about the issue](#steps-to-gain-more-information-about-the-issue) + - [Application features to ensure data integrity](#application-features-to-ensure-data-integrity) + - [How to fix the issue](#how-to-fix-the-issue) + +## Description + +This is a breaking issue within the service sync lambda. + +Examples of an issue could be: + +- Incorrect environment variables +- Incorrect lambda permissions +- The lambda isn't able to connect to the AWS Aurora Postgres database (DoS) +- The lambda isn't able to send/receive change events to the AWS SQS queue + +## How did the development team discover the issue? + +A slack alert arrived in the development team slack channel with the following message: +`Service Sync Error Rate` + +![Service Sync Lambda Error Rate Alert](./service_sync_lambda_error_rate_alert.png) + +## Steps to gain more information about the issue + +- Check the CloudWatch Dashboard for the service sync error rate +- Check the CloudWatch Logs for the service sync lambda +- Check the CloudWatch Log Insights Errors query, then look for the service sync lambda errors + +## Application features to ensure data integrity + +- The service sync lambda is idempotent, so update request can be retried without any issues +- Failed update request automatically are retried 4 times (5 times including original run), before being sent to the dead letter queue. This queue is subscribed by the update request DLQ handler lambda. + - Once the issue is fixed, the change events can be reprocessed by the development team using the event replay lambda. +- Database connections use default timeouts to remove connections that are no longer in use +- The service sync lambda uses database transactions to ensure data integrity within the database. + - If successful, the database transaction is committed at end of the lambda. + - If an error occurs, the database transaction is rolled back to the previous state. + +## How to fix the issue + +1. Diagnose the issue using the steps preceding under the "Steps to gain more information about the issue" section +2. Fix the issue +3. Reprocess the failed change events using the event replay lambda +4. Monitor the service sync lambda error rate to ensure the issue has been resolved diff --git a/documentation/chaos-engineering/scenario_3/scenario_three_infrastructure.drawio.png b/documentation/chaos-engineering/scenario_3/scenario_three_infrastructure.drawio.png new file mode 100644 index 000000000..2e2cd924a Binary files /dev/null and b/documentation/chaos-engineering/scenario_3/scenario_three_infrastructure.drawio.png differ diff --git a/documentation/chaos-engineering/scenario_3/service_sync_lambda_error_rate_alert.png b/documentation/chaos-engineering/scenario_3/service_sync_lambda_error_rate_alert.png new file mode 100644 index 000000000..6e2d7ec80 Binary files /dev/null and b/documentation/chaos-engineering/scenario_3/service_sync_lambda_error_rate_alert.png differ diff --git a/documentation/chaos-engineering/scenario_4/scenario_four.md b/documentation/chaos-engineering/scenario_4/scenario_four.md new file mode 100644 index 000000000..0d5fc0fb4 --- /dev/null +++ b/documentation/chaos-engineering/scenario_4/scenario_four.md @@ -0,0 +1,42 @@ +# Slack messenger lambda issue + +## Table of contents + +- [Slack messenger lambda issue](#slack-messenger-lambda-issue) + - [Table of contents](#table-of-contents) + - [Description](#description) + - [How did the development team discover the issue?](#how-did-the-development-team-discover-the-issue) + - [Steps to gain more information about the issue](#steps-to-gain-more-information-about-the-issue) + - [How to fix the issue](#how-to-fix-the-issue) + +## Description + +This is a breaking issue within the slack message lambda. + +Examples of an issue could be: + +- Incorrect environment variables +- Incorrect lambda permissions +- The lambda isn't able to save/receive slack messages to the AWS SNS topic + +## How did the development team discover the issue? + +- Check AWS CloudWatch Dashboard for the slack messenger lambda errors rate +- Inconsistencies between expected alarms and actual alarms. Using Splunk dashboard to compare the two. + +## Steps to gain more information about the issue + +- Check AWS CloudWatch logs for the slack messenger lambda for any errors + +There may be hidden errors that aren't being reported to Slack: + +- Run CloudWatch Log Insights Errors query to find any errors that aren't being reported to Slack + +## How to fix the issue + +1. Diagnose the issue using the steps preceding under the "Steps to gain more information about the issue" section +2. Fix the issue +3. Reprocess the failed change events using the event replay lambda +4. Monitor the service sync lambda error rate to ensure the issue has been resolved + +Note: the fixing process need to be run multiple times if the Slack messenger error was hiding another error elsewhere in the application. diff --git a/documentation/chaos-engineering/scenario_4/scenario_four_infrastructure.drawio.png b/documentation/chaos-engineering/scenario_4/scenario_four_infrastructure.drawio.png new file mode 100644 index 000000000..5afb17eab Binary files /dev/null and b/documentation/chaos-engineering/scenario_4/scenario_four_infrastructure.drawio.png differ diff --git a/documentation/chaos-engineering/scenario_5/api_gateway_5xx_error_alert.png b/documentation/chaos-engineering/scenario_5/api_gateway_5xx_error_alert.png new file mode 100644 index 000000000..aa2809b8b Binary files /dev/null and b/documentation/chaos-engineering/scenario_5/api_gateway_5xx_error_alert.png differ diff --git a/documentation/chaos-engineering/scenario_5/scenario_five.md b/documentation/chaos-engineering/scenario_5/scenario_five.md new file mode 100644 index 000000000..6a12467a7 --- /dev/null +++ b/documentation/chaos-engineering/scenario_5/scenario_five.md @@ -0,0 +1,42 @@ +# API gateway 5XX issue + +## Table of contents + +- [API gateway 5XX issue](#api-gateway-5xx-issue) + - [Table of contents](#table-of-contents) + - [Description](#description) + - [How did the development team discover the issue?](#how-did-the-development-team-discover-the-issue) + - [Steps to gain more information about the issue](#steps-to-gain-more-information-about-the-issue) + - [How to fix the issue](#how-to-fix-the-issue) + +## Description + +This is a breaking issue with the API Gateway. Which would only occur during deployment of the API Gateway. + +Examples of an issue could be: + +- API Gateway incorrectly configured +- Incorrect API Gateway permissions +- The API Gateway isn't able to send change events to the AWS SQS queue + +## How did the development team discover the issue? + +A slack alert arrived in the development team slack channel with the following message: +`DI 5XX Endpoint Errors` + +Deployment team would notice the issue on the CloudWatch Dashboard for the application. + +![DI 5XX Endpoint Errors Alert](./api_gateway_5xx_error_alert.png) + +## Steps to gain more information about the issue + +- Review the API Gateway CloudWatch Execution Logs +- Review the API Gateway CloudWatch Access Logs + +## How to fix the issue + +A potential quick fix that may resolve the issue is to redeploy the API Gateway using the API Gateway console. If this doesn't resolve the issue, then follow the steps below: + +1. Diagnose the issue using the steps preceding under the "Steps to gain more information about the issue" section +2. Fix the issue +3. Monitor the API Gateway responses to ensure the issue has been resolved diff --git a/documentation/chaos-engineering/scenario_5/scenario_five_infrastructure.drawio.png b/documentation/chaos-engineering/scenario_5/scenario_five_infrastructure.drawio.png new file mode 100644 index 000000000..99b595c1c Binary files /dev/null and b/documentation/chaos-engineering/scenario_5/scenario_five_infrastructure.drawio.png differ diff --git a/documentation/diagrams/C4model-Component.png b/documentation/diagrams/C4model-Component.png deleted file mode 100644 index 816f4c11c..000000000 Binary files a/documentation/diagrams/C4model-Component.png and /dev/null differ diff --git a/documentation/diagrams/C4model-Container.png b/documentation/diagrams/C4model-Container.png deleted file mode 100644 index a0f27769a..000000000 Binary files a/documentation/diagrams/C4model-Container.png and /dev/null differ diff --git a/documentation/diagrams/C4model-SystemContext.png b/documentation/diagrams/C4model-SystemContext.png deleted file mode 100644 index 54bc6db34..000000000 Binary files a/documentation/diagrams/C4model-SystemContext.png and /dev/null differ diff --git a/documentation/diagrams/C4model.drawio b/documentation/diagrams/C4model.drawio deleted file mode 100644 index c0cff7da3..000000000 --- a/documentation/diagrams/C4model.drawio +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/documentation/diagrams/DevOps-Pipelines and Automation.drawio.png b/documentation/diagrams/DevOps-Pipelines and Automation.drawio.png new file mode 100644 index 000000000..8b6267441 Binary files /dev/null and b/documentation/diagrams/DevOps-Pipelines and Automation.drawio.png differ diff --git a/documentation/diagrams/DevOps-Pipelines.png b/documentation/diagrams/DevOps-Pipelines.png deleted file mode 100644 index 366bc17a0..000000000 Binary files a/documentation/diagrams/DevOps-Pipelines.png and /dev/null differ diff --git a/documentation/diagrams/DevOps.drawio b/documentation/diagrams/DevOps.drawio deleted file mode 100644 index e9098bd05..000000000 --- a/documentation/diagrams/DevOps.drawio +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/documentation/diagrams/DoS Integration - Business Architecture.png b/documentation/diagrams/DoS Integration - Business Architecture.png deleted file mode 100644 index aef7a5080..000000000 Binary files a/documentation/diagrams/DoS Integration - Business Architecture.png and /dev/null differ diff --git a/documentation/diagrams/DoS Integration - Infrastructure.png b/documentation/diagrams/DoS Integration - Infrastructure.png deleted file mode 100644 index 7eb4b4baf..000000000 Binary files a/documentation/diagrams/DoS Integration - Infrastructure.png and /dev/null differ diff --git a/documentation/diagrams/DoS Integration - NHS.UK & DoS Integration (1).png b/documentation/diagrams/DoS Integration - NHS.UK & DoS Integration (1).png deleted file mode 100644 index f0430bcf5..000000000 Binary files a/documentation/diagrams/DoS Integration - NHS.UK & DoS Integration (1).png and /dev/null differ diff --git a/documentation/diagrams/DoS Integration - NHS.UK & DoS Integration (2).png b/documentation/diagrams/DoS Integration - NHS.UK & DoS Integration (2).png deleted file mode 100644 index cb9bf9fa6..000000000 Binary files a/documentation/diagrams/DoS Integration - NHS.UK & DoS Integration (2).png and /dev/null differ diff --git a/documentation/diagrams/DoS Integration - Solution Architecture.png b/documentation/diagrams/DoS Integration - Solution Architecture.png deleted file mode 100644 index d026ff001..000000000 Binary files a/documentation/diagrams/DoS Integration - Solution Architecture.png and /dev/null differ diff --git a/documentation/diagrams/DoS Integration-Blue-Green-Deployments.drawio.png b/documentation/diagrams/DoS Integration-Blue-Green-Deployments.drawio.png new file mode 100644 index 000000000..11938ac2b Binary files /dev/null and b/documentation/diagrams/DoS Integration-Blue-Green-Deployments.drawio.png differ diff --git a/documentation/diagrams/DoS Integration-Business Architecture.drawio.png b/documentation/diagrams/DoS Integration-Business Architecture.drawio.png new file mode 100644 index 000000000..2f727af71 Binary files /dev/null and b/documentation/diagrams/DoS Integration-Business Architecture.drawio.png differ diff --git a/documentation/diagrams/DoS Integration-Components.drawio.png b/documentation/diagrams/DoS Integration-Components.drawio.png new file mode 100644 index 000000000..81fdc2423 Binary files /dev/null and b/documentation/diagrams/DoS Integration-Components.drawio.png differ diff --git a/documentation/diagrams/DoS Integration-DoS-Connections.drawio.png b/documentation/diagrams/DoS Integration-DoS-Connections.drawio.png new file mode 100644 index 000000000..dac94eeb6 Binary files /dev/null and b/documentation/diagrams/DoS Integration-DoS-Connections.drawio.png differ diff --git a/documentation/diagrams/DoS Integration-Environments.drawio.png b/documentation/diagrams/DoS Integration-Environments.drawio.png deleted file mode 100644 index b3c79219d..000000000 Binary files a/documentation/diagrams/DoS Integration-Environments.drawio.png and /dev/null differ diff --git a/documentation/diagrams/DoS Integration-Envrionments.drawio.png b/documentation/diagrams/DoS Integration-Envrionments.drawio.png new file mode 100644 index 000000000..a788a71ab Binary files /dev/null and b/documentation/diagrams/DoS Integration-Envrionments.drawio.png differ diff --git a/documentation/diagrams/DoS Integration-GitHub.drawio.png b/documentation/diagrams/DoS Integration-GitHub.drawio.png new file mode 100644 index 000000000..533cb7077 Binary files /dev/null and b/documentation/diagrams/DoS Integration-GitHub.drawio.png differ diff --git a/documentation/diagrams/DoS Integration-Infrastructure.drawio.png b/documentation/diagrams/DoS Integration-Infrastructure.drawio.png index 5d0461501..42dcafdfb 100644 Binary files a/documentation/diagrams/DoS Integration-Infrastructure.drawio.png and b/documentation/diagrams/DoS Integration-Infrastructure.drawio.png differ diff --git a/documentation/diagrams/DoS Integration-Security.drawio.png b/documentation/diagrams/DoS Integration-Security.drawio.png new file mode 100644 index 000000000..5399e7568 Binary files /dev/null and b/documentation/diagrams/DoS Integration-Security.drawio.png differ diff --git a/documentation/diagrams/DoS Integration.drawio b/documentation/diagrams/DoS Integration.drawio deleted file mode 100644 index 682e19f0c..000000000 --- a/documentation/diagrams/DoS Integration.drawio +++ /dev/null @@ -1 +0,0 @@ -7X1Zl6M4uu2vqbXOfehaTGZ4FGDAmNHYBvNyFjMYzDzY/PorERE5V3V1d+XpOvd2VmWkkUFI37j3J0H8QgqPp9wHba43cVL9QmDx8xdS/IUgcAxn4T+o5fXWQrP4W0PWF/H7SZ8bnGJNPq58b52KOBm+OnFsmmos2q8bo6auk2j8qi3o+2b5+rS0qb6+axtkyXcNThRU37e6RTzm7630jvr8hZIUWf5xa5zm3r55BB9nv09lyIO4Wb5oIve/kELfNOPbp8dTSCokvQ/BvF0n/ca3n0bWJ/X4Ry7I1Nfz5fvXaa+0++qEA8oT/vaunjmopvcZvw92fH2IoG+mOk5QJ9gvJL/kxZg4bRChbxeodNiWj48KHuHwY1pUldBUTQ+P66aGJ/HD2Ddl8tH4C0HC/yQ0RP77GbxPak76MXl+0fQ+IzlpHsnYv+ApnywMY96ueTcwCtu9HS+f1UWx7yrIv9AUSb83Bu8mkn3q/LMQ4Yd3Of4DMuX+vkyhLbToY/HY7I9HEy6g1WlBmFRWMxRj0dTw+7AZx+YBT6jQF3wQldmmji+kmW5/vugDVEWGrh0bpJtgaN/8Ii2eSIn8dkvw0Yp9tMDPcTAGv5Dg7ZCQ2jr7hRCKK2+eFuwoZw2Afwznku8vGfyE/oIDEMAN/itiiXGmUQuvGIJztQ8CyA4pyMsCNYJqcaRqRdfjCzw7jni7KMJku6DCTtccuxDcI1biPHpcQEzGpPaopoA07jePr7QH9/J382TxcAwCAM7lxF+VImJinJwE/FonKRwv99Jq7VAcD9nNJm7dLcLoWxZggjoKRhbYByebpFd125eeOBt+PAbQ/nicOfX4LVlSc7JusBPPvAW3eKrS6iRLQhjY/kgwAS5eChmePeOnHJ50E4zda4FqlnYuk17hF9YNHUUmu5vWXaI9Yi6o3HG8amdjXzit9hz2OQ7K4mzU2RWUna27NiidqLP5i+C04OkD4aS+WiDZ6rEUpRKo+/pYgv+c+7/93PySV7MXY3xR1Q/sopSq412NmwImVtYyK5H8UX61kWQbqt5qhwQDDKUt0gQaShWdU7VmhywIWsdNOMuENgZSvnMkD9meoD97Vi9eFxVMoDWrgx9iaQTCRZ8Yeh730oCxZeBiLtiX5yrqaRDaMGJIT3mAJot595mo5Iwd4bh6p76iseP74zRyGVApNRRbvmdgjJSMsix7O5cyXj0yx6U2OYwyrrAPSU6Pd0rW2h2uYoMPZWGPzqvv55t4zo4mv57SXXiKiAbbr/PVcaRhEA5ypV6qOt2TmcKKbVa8riwNoyMPVmypqqNdDQt/cNvTBa8HoV2OM8/40bOM2Zkvjb4dh2NZa74t2+NebnsrAmRGsyDO6ceJW3U4KJEvbq4zygc477qv9JY2YPf8uGgKMIqZnY6NL3o0bGMrx1GHi3AwSxOOaYa30xUQDwYtW3Gip52rD0A9AEd2OZ0ETiQcF3gD20b5geBb9WFYd5MXOQvLTjdwPOttqCV7L9NY2c+erypa+hGe291gP9Lxcamq+UxOZPo8LuzOnqLndcF82BvvcoejC4RyPTovMqOoer7BNCa9dud5XDv/7lBh1HulZutSBto9/RhGWuIaFPXGRabWyTzP2QUempTYOdWwFw46sbsa0CJoJree3i29BchoSnuV52eej8l5BLYL5MpEYy7r0Jsrk7fcGCIC/jrx+NlEU+ygPgB/iF7BlTsF0Bpg0JQcZIdMXl8Or9k4Wjx+Ubyqtw/QQtpzB2fQroSvMedMIwSKuWNneFlDCZR4ieDIjcVIRH8xGZ4I+YaHwV3JGBoau9Es6JzLds41EU5nYA3SE5/CeOZsUcqE1sdVP1wOEfAX3hIAtbZ4DMilNRunvMdDaTZC+URivpQdzZq8sghp602ixQoBkqJ6VsGx68RDx87c5do9ihud0bYBNRxnugniRWGedOg38sTPfn+itPgErurJsmtKtITMv7/Yg9MpOIiQybaADh5Kli71uEDP4g/nUG9qioJfSnzCPlJfekkmiNzl0sUTHDWgj6qHC5REgiWWffxREhTPIQm0+J6/VEoMDUvKWiawDJoSucesLI8uYy9jtAMvHneEvt9x+y0QoLSz9uo+fSLIwwssVd/hvxzO2EYkMAvbZ9Slf7vOhtf5nI6uO8BzTHidfkbeT3CSd16ueBqClQIxM5/CM1HLC8NHlzbZCSU4VnpPy8ED+fsrXeK0gYCLl1jXTLPZVDtGnCnQ4Sq8sO916ZAiweRAaMUj/FTU4BGBc0Ybuj3dZYt+NEYnx7AvCKN4L0dOifKw7znHVtktfCnfmiq9nKH7I19OBXLVUwhEpGXOCnbve5kYhQuaiIXtpDGahqXMOcp+AQOYbc9zWsIiNHA3s2EP/w1ypPfj+S4+1hRiO56ZkfmOZWpQ+nB5wM/UvgPYCc5QwTwmGrm16Q19SV1M3vxcYs2ihVbTiFk0uGzgkiDj+ieMEq9WLskayLbaCTCmW+2TpGqIsvwb9ExeXmtK4+43Eg1nNLKrC5D/xDeI/a5opgKaqcuP4kzOTka9ehtfDmaIJYJysSXnkmC8B/Bhup3mhChXpfLPRXHGi4Llndy0X3Vnv+ZWW6wEIRd2n1LmdMreYpNgMEdsOSFjAlDmY7uT5WM1p8DO+gvoYPST4FAWVTwAT3kEXtN6XIjlAWjDhmSVMHBPTRuwFHD0Cd6pT69GkPJEn7WJjSP9ww5OdxCmgr5bTE1k9B62kGHwaJi7pSI7MG090y777V4DdHhewbTdrep0rVlMGEd5FQkGf1x7CpjYsPrOWC/qRer2VyNkwDES+5eWZtscrqyweUneZaALikSYV4MXmS6IcEjEeC1CIYZ/tRW57A8yDo7TzMBsOmXOxDdszrAgpRzznM0EqJDxcb2LUuAORDC4LkIZoyx9ShcRwsnFDPko7IvTJIol0kOMWYlF762JAkQeqdZa5OvOoJ+nEgQO0fYoUJ5ecycue2T7KNuI2czcttioUfpa63yZEnT8uLUdviDDr0c7XDP+gPxyWswadKwyZp2J4d4JmSdHtCJJxDuV4EdGZNvbxenJh3gQXtXQ0hx+dneL5AEnLSgNBWySeMJMPok6s7iETB1rctaXthQuVRCBq+Ci7Ma3kL1gWmTAmIo8U6SZmjtTVtam6fIYzWhvydWxfnF4edfBq2w6Gscd12eRrlHYz8LzHd4usLLbdqcDoUzH+s1H9lfJnmukitrZ4XcIgLZ7othxNjp0NRrmba3Or1nrFtkmCmN5+bhxRF5+LknJd6WLUS/7m/hwi75XbgKpWc8ZDRVQaWqHlGi+xeLTk6KyZYqRoIKouuuU1DqmHAFDjR0XKTZ9zQ1GqVwjkrsnGj+f3NfkvngeL7v+kyNRHChx40R6iYzvLMEEZjb52jGpIGtjryZfrel8PpB75qmei66zbrK3poLjLkoNbqnOqq0joDvy73eE02pL4pGyDGvxML9DszpTiSfIxqowq58nZdN38ltOvZ9WJQtKrxyhzXmjWuxmJKDb4X43ZmV86PyIvGOqs67rCBBQYgwK5j3kC/nq5ZTDCnjrI2nx9NH3cJikn6PAjcypjPznLt7fYewhM0m/IUS0WAwYIqFrzFGDIIGP7sC6Ho/TMF4qFAD3aa4QAx4q1qs+kOd7vVxqvnpTrIc8Og8i228CfxEvAsrOdMTd5wiiQJgmkjC+RjwEFnu3HvbpXi156GSUhsiVXq4nGCAlu6XdFwmhHhfL2MRJnAiQ77vXe3wHAcm7yW0Xb5bgVSPKz01pHFCwBHomNi2Mko9RR4cCcnP4d/GkF1vPWblDWXPP7cUMRnMjCO9sTYH6ZAcnNVp8UCOdtsYFLyPBWhDSut8seJ9eIQrZRmawV9f8JADJx+8hBS1COZWx3TZYtcjdfkMbR1ZSsnqQuKWW1lgYXzO03umedXbxgMleTsFY7X18supFY4zEuiu0IDqtlR3so+S4TppdjH5McjZ6vHzGo8o3dKDTBpOJJG8ReWyTz318UpuLv4DLDSGKVRkXOFDeJsRTwGo5RJo5gevX88XFj9NyzLsXR5MZ9Hm+RBNa93Qh2uqcmdnjMiLd2Vo2sYIXpeJclMN9c0Ep58R4OXGZg2IUS/NwnDBBGCLMnIAz/RSzFoWVlTyIT2Rq5zcY6qWp17AQM5LHoe/ceJ+3e2R3wkNrPXygwJpdYsXn9rRnWwNGLfItnZOWVmlJ4Vvccj16D7POrn+yZtgcDBWhAv6YtQVElLEJc5Bp3Vk4+GtzRoiC1q34LYWRRLPiL2KIePp+rYF4enbBKz0rswK/p8+NZnRNQMncY1Sy8R09lciPbju8RCiEGZx50RPi8EJXPJv4JDUTlDCmw1xfYzzzfLsRRgvPYNxzCkQy2r27ptmKnO0+iExmxDK/k93XswT9NYOYMrXJGeEKfe20arKJm8Eh3bTXA3+5SvM5nF+rgzuy5BbgtHr3W+o8X/HxBRkaeO1CNPsW8haBleeGENMnxxmetiBoDjpknjVlM2PM+i3sjQzGKg8MnuUDvXLcEwR+nPfmlhDduK1itx6roaiQqOWpfFCijOW4zxn37jBKZxPI99OlQjIxIOq8EhnJesrCMKmGZOZCNFVnF/Yo3kVVfZ2LR8cgi7jcS7E54DCsQC7qB1zOCeOF67BUZlh9rRjQRWKL3ZTSD4VqEXCvQ3gdGSuos504r+uOTwE54E0kGZdMOl3EBU0bCXVl57BRzSPwRZJ1lMsp0NDs9qYJll2d1rtDh+JC/tLRJJdbreMcvLTXkrvcHzos0CkQqDArJSyCgGpOzKH1QHr8Fq21caBFKVncuuVpxyS0AY8azINDyPrCBzVjLKzKKTWFbd4YRQu5gcoMeG2ndQTtQ1ZuS7aSwMze54uknMzz6Sl6fO4jTavziZkXsn/uDoiKR8Qx111g4DyMqvteR2NBf62GZvGG1a1MPEQ8t/SmLuN1ezzSCWkfAtdiDwHLmE+VChX7cL5cPK9SGYd0gBwXCdcuoIsNVrgi2n6AcT4ehOsiEsVA3Iv+kcsZJpVIwndkQbx210RR9MBtNz9ZQ4PeptEos40+Ib/jQlaoSWKGBmPMO/8OnAHdI/ZLyYbsc+gpVMWSVEyECbmHjOdk+4dTePGYC+2YXnlhzb7Zw15dwGPTMjbGaiF8ZlUH/dlHKJaalhYl8zpAPMCcDhAsIn4tqb61RBa3qzxjOaT37AVjZxveeMc/xCCx+X1sV40nYOIbR74niUgM9DwKOt9Do5v57gaDg6hK5nnv4iGUHR1RAxAbnj2kZ+Q8bYzsJF3244J8gb4pJj3Yp/OU6ZA1+8hi+JqXYxu31tW9YZO4UAtpPm/gwBpPmIm4+nI8BWBWMQTgDhpCF2YveG5soo8ssWvUy1XbNR1LLxDzd1V41SozDKtIlDaceMZuieyXeUPvLLIe8mORQO5cBvK7ZXKejsP2qTOuq5vZc0TUGxYlY6/pujtxt7E8A9H92lG8cqJSONQFVR33z03H0yLPhb7al3jgd5ijlDpUmYpf8HE+W/OMAoPVUvqzjaTdmnMR7y6nB03nF233Ug+Ns6BA4ZTg/upib41EhVr08tB2iMmKtxckpGg0ww6kDHt17kioj1sJuZjIhvUwH6cAS8R6OVUJPxdRwuoJOC1X5aLuhkUsdxctOF9KbMZXGn9ydRtogX7u9uzjwr/2NmIBfHoKh65AvO+qwnGy6orYeFODNy5rgPu0WPMd0HkqlUCHDDLJJjpyk4ZOemEmrBmVpdMH4h8wqlCN2ZyfPrC0xZ+gQTp77IqgCHaRHWglBsITBZwXN/Jl8uxgKGrArmJb+ry+TAMkPWIU4wT05yKH/CNtGiKpmro9xeBePZcDqvWwdE56zz0CrzO5CBA50U5BZDvKcx25viNSLMWuiznyBTJUoOy33Hu6R3cHU2oCeugKw+yaHdkDSeziQUnkERPMS2VcSCye5CBvPFQ6nlEQj3viuRsTmPkJJOuuA0CTuTZyvPl298P5ziL8s3rZbb4hpenTXl+shRYyhKYUZdlFM5+y4oJimhDnCJiKHBqbuI2trWbHWuYpix7ZA9nLQHgHyNytM0XnlpoBP7d8jp/0TtvFj3KV64XRS9B1DaTw1Kzrh9RN9jTwB2TRvqI/qq36vVgoOtpnciahBaZ5kfVGBq4o5HQrWkNJd2uEyjQRYm85wVP0YRVFhWynY9ClzV5tZ6QbGIDoPBLWpjYadbxhDQu9+VQnQMuJ9NwSgKNi5aJB/3VB9DpeOT+ETQpzctMTLmEQfbkwXsABGUIrHINs3uoGKnOSvcMiQNr+xhYtsjmorbVmon7fwTyAILLUvnSEEhZXQadAg5RCfz5Z4/HcDgdBBxDnx+zZFDA6J8LC6Jvn0bq/SGJhrZN3PUBcZKjsACwLLRCETljIqBiThhdf9KhAdsAV900evPTVNwP2Kp3C0y1FhURtDzhpQcji/uCyXZRUaTrf5HNTmk1a3x0KcIjzbdQyzQ4mjP3ZAyZwOeMQpsMhu+cyFNqze2QN5C0eJHZVKuqsXM7r8hb1D1vdjidwiLXqFozkVsvJVgo7EItYn5rUTpW93BxjFCcRvOELyiVtd43sOdMIVJN4dEBoO+UGB+EPyu7KW1rpo7zEMdBfDH43simDafzbHaNUyF8sxMwGQOUi7BrUzdhg0GEf2eJmOUTWzMJG8g4XdVLnWEM5ADApGHmXT9pLAQMUKMdDgYoY9uphZDlecpYlGgibstVk90ZWmwcjoPDTxcQS5WJxPlAaZI9aJsf7HSfqhi5I0PbBiNkKhnR7k065kuPi2whPananUbW1p4AnprTwCh4BStYK/x7NEeOQdiU/wlwYieJjlpd7RsP4JdUHuZE487lVmAwcR944pbLFEesxJm/7quF22ROct/5nap/g9M308uJxw+S9DaPTFUfpCma1w3xQpG6+nXW05IZ8wWxqyLNoCt+8WLHOuYu8O2TP/Myiwj2K2SiaZjZdRJqNYmc51JASalqjqF3rLXJaLFuG1YjsmDF9Y7FauPUMM6yC6r2SlMX1RV+5nfQwtCw3dfM0GdVFYRZOiq/cZnyvBZDnpCDTJ6pthqgSwys8jF4ijAF8UB1hdIF5CMr+BjPxUKWeuShTERH3x1vmhufroioc6UcN7NdhDfal6WAejptw7Dkcu5bETji3yGW0Aruz4/PVW0LMOy4A9BHyC4DyJniNexLFanZA8SNLgIjsWm9M17cF9gCzDLzToAuYRPSBmDIXiaajjUaAVihsIvfX8intglcuQTSCoXUDwIqpSnv1a+KLly62Ry3Ye7nhg2B8CTvq3AD3ZUPgAJlCGuikR8Ps5/A0BGsegB7mc+JRgTj21V4hGss020rYu+evSFBeZUCoccj0Yd7zRCrb8vl2YnIiP2kAxCjGkYMYNqTePPUEeRZNgWV+zoedtqMvxdDHNnPlpYafzBtHXNBUq50bZhm3zzMt3vMcv6/96fiaDXGUKDc77A6QuV0oTWwU0D+vyIgt6wCOj0bJ9qx1P4rqg5op44xsYBIwoEwGt1XBuoVDVm4guhSej5Drcw1lNi79AspZlBFjuKLIoT0qhCB5TubLXIrtrUjt731DvIbYeRdUJiOAHEAbcKdxS2bHkosnC61+7Bl+F4PcWB257VQKOqsW3xb5YNBhPO+t3WR3FBwhqsuPEQx9yvF5PIVXH16DF8sluwh9j+rEJzO2H4ZnBAofRhAMUwdIcbxsYS4vawsVmuRSJKqTTGkTs3rd7HVBxez95sicCfsMUZ857BPV0atFsoqBzre4n9yBD/8pAPSbqfNHmKdfg4Hs+W7TGQM1xyNkCnZLWF+MPOwu4WvdISdukxSn3SKpckxQXJF3RLmD8T8WcssjdnJGD2+YIY+p8GHLeZztXV6P7RGH+bZ4Xg2fATSMWLjQaMUKg4p0gEjmQs1QWgf6IHYSJTWib1IcES1MRcfqVgvYWIg4iD3F6A1krmcM4mLPPjzDA1ghl4QeIObYGYLabCItwtowlvqKBy7zYGzFhQEXT0Z8uA2tpiWidUcmYPj42qEFCahtfCpA8Rz4Wr/OjV5MvHKbn6XVI9SV302gr+aDFJeVI4J3thbsUeo8TrjqIZuBWUEabFFHNU7yxpMofMR2J0+8fm/22kyelRBrIHpam1Jv8uyaGeNOm5aHws5ei+pnoQvTpuRYfE6dHxc5jo9KzZtnFdz5qguWlO+XyWxigbSDochofkPTwbhE/BPmBMyE2PDoz2JK7dACCd2v7n3BWBRD7hN/WlzUJzfsIKofFZhtBvPMGxen7w+UqLyNdhIoBPHOgxmprUcz+j2SfEzQCfe1CLUKWfUIGdlojmMVoCCGeHWjbzVqorkSwrQfS7H2kjAS7qjSpiwmJ/EGP5tgv1omlN91Taf31RApY1gizI0sdppupO5mDjGUUKFMabLGuVOWGUMAUFbL7HBJd0Us+5UwW7xPnaDm19tNuom92XJ4aZM1cxH1gOSdiIprkA57H0dIrEaRTrt3+3ZgBV2GkVBlpVnI6JxVY5FcMDM7L9WUAbrFuCw27zZxp1NQLVmFamG76F7HUdawJMyT/WExS5jVkeUoME/OnnuOBHGrKC6x9ejaYfAzvaTFDGnGto1IhukDVYxxlBvCIJPRiswSNF0EfcG+GgEDVtjy8FCZsp8VYpRtzHznix7RXTemnz4uJvuJ0zdJtfAP/rUOGu/R2ESKZZxRiMxfJ1qA9hMiLhVDaq8WwpVzTNDQxtrx5hTsAzHEbNm1Cj51nyU/EEaaSidsfL3A8WlvXB6EkbW37PuWhW3utXda44ykeFi7vQqlOCjHuTEwFgXF5spqMO4o965++T3yAnGgeSGwwAyE7m1tV107xZz0++rmi028WTh4Uv7jIq107LYdT0FWdTz7tnR7xEZrsIQdZvZ78kXxjcIVu7YEP2Nglqicx5VLuWd1RogeyROyW2Ql1401csvNLKJXNqV+QzRmZQjRAPSRBAn+kE8qhlzWRX1SktFgRtO3RFw8egaNWoSj1uGo04JZtppTRDfP2egsfuejeps0CswywTz8ipQ0u8OsDsGdtvCHkbHSNA8HLr5EnjHvdmLIn2HsrajodLmCDRd08nVYAASsj60WXOHTey7WtNdt597GR36dM6gj3gXqpSrRxIML43IQ4497JcgXHa0AI4ivvUg40xXZgu8+gbAiBr/waWYoI/zBqjDe9KV/uoPRuaRlER8gWuRvqK58z6RYgbG3TiGJFANa2/BKJmY0zG7VEVMojTrP7o13Dw4KcinjQcLxhiBbE6qFk3MWpMuhvbZH9aUzTgJByrB2Az+yyoWY6U7eFsyUHKoj9uJM0O/6K2fCJk1uzwDcL3nFXPY3t40mzcIZK+SvSFLMc7MEiOJVzV+U2wS05bh/jYy/xf/JPwhShZbWEkmteLcVgOFTZ0xk3QzgaCfI3G+lNM/zcmbmIGkXrtOkn1Al6xDaGORbxkTxBLbMbVwh1uwqy9uKnEjODswmN9I+aD4FYwruuGc2TeQdBmOZSUMUC0HEyxa6Tl0OyskNbPwhkaRQjFeIGendG2/XI6IP5/ItEyt3Erk8RVu5d03HfT6KmZspV0ZAm9gkmhe7WuTmU4PI2An4B0iqUmrvnuTAhgqEcKTfA3JfdvSKCrbQfxVj4q++ki+Cya+BIRbSuGjG+RJii9nYxJqpC+KmqR0+qLxPSA35W7xllHMIEIA2VgGSg/nRdloA7XFvDIswqL6H3yjJQ2hDJb289oV7xhOFjRVaJG64B9o50YsXw9jXD88Xj9NoZPuxxRnjGDATBvUtxGurncKLyspWoNBPZ6OWJ0KLfBEzydUIadO2FhVLc+rI4TSroQC5jAsrIRoQHuaaV5yAt3mkyS3nQMk7RiR4TWllGCrO4NhORhaSQjzV+5nUzL5B7KE213fm/M5iLsm+LflHKV0Lll0qTfEtdkazzIjCu5YVGRe4DRkgmBArxAlMppLOPQ8FC0Z1dEP+FtstiXXvUQXfokqyCMqJCGx1EjjE6GH2zU22o8ERRotcWXoUK7pzSOuhbBHs3kMIfZz7IWSPDEz+3T1UI77ttWQ/Z/yw5zAJzu8UQeRqapAJDmF3XQyGl31719kuJUBtW7lIH8VO9VZRhwgwFVHJs77mdMxpkYWOJCPTXf4S22gh0oHxE5VMLQpCDzM++RVK/2FS2GirhsRvmwogPywFr5Uh9vROh9g+soMAtFcat/oxuBKIRVj6EzFYkwRNtDcw6fEQ1nKUxusVE+TrDWy1zT11uKOoPniLBCMCk8f6c5ec5qFoRHtmsilSeFx4nFyQp2jVCaV8/jEyfW9DtB4pBrSuy1ackOvsxkm7HXjQOofWnnuzPE+wH7CYE8ihlyIgtGXq4UTXQGRTmPuDy5EAEW2IrcyE6m2TrJBm6iCNSLLuc9efKEDCKd5qyO+SRYlbHD9ta8e8uq0oes7BMy8Yqnzwk2K98DTDYo/WLvdD6mEmdUSVK+lRCZYQZRceoWX2+BiU5cUez2ju5Iavlo2Zypyp5xy7PzezebiQhywCT8yRXtXlwjcasUYM4h/hJaurmRpPdz2pUZyG/RiCMjsXF49I56DoOAdgfEzt+8Ihi+rtHiEsGTIhiFHwBdU17jaWnQdhXPx6uwIH21r+dfFq24xjWkIJvstLK13YCMzL02wGDLicDiMMiA/3aFUuPNhD1IuYz4VhpFTYwXikYbeXQrLxit8vqTFcOPfhT2E2guM0cZlk3iP6vlrjEfJcMg566NkMdxAhRm+VKxUUXbfQqriTT5OwiKOaWyGyr5bDDilMLk/gRKAvXHe23vZnwHuXt2mnAQEhsv0sLHS+9jO7rR7MzV6Yr/dDQm4LoJUVxYDuLQag6rAGda5NzgAx55OrurP0aKeWJW7sZuTBfhTaaHmVPkwDx65lIBY6LPFyBRBYqCcqgEgj5lgcVQFYc7BLI9kqWDTPKYKIE3EI+G0ewiSUkFXg0Mw82+Qu6oxWEIrHjfXAFXFwZdxr4lU6WYVNbR4TdMDgJ7WXesS6yNYDT7R6+Mj23lbv73Ic8k0IOkwdVToebSaUQ06B+kyqZ594QZ0Zu/l2W+VtLoI1qiWqO95ZquhvyE4xOh7CrAHMDfHMsbyOK5IIWDFHeWj+w4IhlhX64LLQ1XxvVsl316nSXxJkgFV6sWFkxMTZE567+URpPYxLEOcdIg+4EBPseHexErrt7hBEQ4yPiql60EsTf6duiqshiaBcdUvs6yTcIIqbGLUTyDwClEmAAAtwGYX96x2g7Tn3hXo6LE4l0FJNhTdi3m8f56CyYjXec2EPWvFIVMR95E/rlJ7mG1oxRTgG2MSDtNqQpnWYqSSoMVdLOK0B2J7hJrRzEpMm09n4EvSAo45/FDCBAGlly3uULTTdSiZ+Y1BuJp3d83JhzUunVYm3UaYuYFPxvhyTIsLuGgw9MxyRBiZ+KxtKeFd0lKA1pYE91QkwMAbBUdTnBYC9zaunvXQp1bCtUF6AvOvBw+EBEcjUXkR1JAMiiYaFgciR6YYvrRDZFhoiiV0fpR9nR1Ng6ae2Nh6K2CHWMz4iU9hDY8U5L+MDRIFRKKbL0eSFp0R3hojW/K1zv7fMPAa2mCBIuUTQljn2ViF8JMaHYruqRysKA5VrsdltXOCWNhNOXZEceO6QcK9hfHYkeEyZrZxvyMBaBOT3xuFlqXfiEvJuI0VSZoXSzOyqa7vMBm3xOLTlqWt88TlnUmjDmNVyEXHruhFiBWQXkC2F56txm/kddasvJlfOpDNTePscrBRrqShRcJRN1DjTEvlJFCuunWO0jLoMcGwEJySUR8+0sHb3yxRiCYqgCn2A+Klj71m1Pxt1PIHTer5Trney067RVh2kVCDOu1BGGYatAj0U523hR1Fx0e1UI0KZ+OYnI7tFVkSosh2Kre21zwROgs7PF/m2nVE6QoYz7k8WqlcminOwYGABgRhzstwjxAYpB6rGaFDWyN+htB9EQaI9A4wHx+3sWyKEXAIqnOcQeCiFEu0ljHitqc3mSlPSC0o9jV09X1nFK2kml7SugPDTKnp3XzsE2PbDrlZmhE4aoH27VKgEmoX2RYZojVXp0r7eQctDfgDFo1qmBNG7K3+gyiqelzwQVCQJFGb2nDB7WjWIaAmNbtFONcjit/pEEtsM+cLf8Lqc48U9XxDmR+xYs/ca1kpctEIsS55etgomphbPEMni4oCE7U+Zf19ZJGvsYceQm+9kd1F0QeqqFUUQ7qKAYEOu0QSjA2ServIQUxRwRcq8N4pxgEQwo7R7C2/hTMD88L8mQYyDrlvoPUfyRXT1bnd9dd0Ee6C11hRDp+0P7F7Jt6IFQ6JFFx7MDWbpUxiJay80Gnnbr01jYp4bH22YZbRIIj/Ox5ptRbSkn+IT7WwMM4jO4C2fr/h07coGN07WeaFPaoy3GQDtopAFoHOyL5NNVFjBLxuThGjOkTLiekhDcBzOpXttIDW+OtERVYGQbe9rJGcNTUtC+9dNOFutRbtD0JBHLKxLPmyjSDiheNHlOvS8m9S8iLsNbyhPogCZ6Q7ZmhGhJ4yMQwHh3bHrRxSfLl7GO7LHhaYgoeKXg0NOkelk11RPUYIkbAdc6kRj912Oq/R+0/2uzhTo8vx1pRFhsNEe33sdnBuDk/zd3qUCyFmtxBLHPY6ddfBcLIZ3Y9vCyW0HJJTEaXjLt0K9HMdW0a9HL7xJjBs/JRdsMY1hdbqh6ZmsxbrL3jIaKktSZNVz7EG9XNGRCu9RlfAezGIQgvZ6W7aZRm+DaUG1W5BWjBRFp8JTi2MwW81ewk4XOG0X7YF0uM5+BucSWWGzyAaqzx8HXyRY8Z4JE4yERRih3T8LqCEPbItdBnOh5xYkIh7penpgihgJCgWx0HnsoC2kgErRGv88nabppcMMz5wOaZM1GSQdkXBfHlQ/heIO5VA1PR28k7d6d0o5v6RzTJCbP9xpczVGtMNgpBJTmVycO0dsguoO27IKBin0k45trjvgtFMkjxNWyMVaZ3tKifP1OaZob7uVP9DqtpLjgkxeBtYgkRxkVeYNZor263EBraYiM7LEh3vFrYh9GCkJxW1zMw8oyToavIfqIDU2DHdPk29Ag+HsDrPX3ZnEAsapoIa6mdWW8CN+RPlJPGcM5O0EtMZiPdrwDgh0yso90Y50NYUu5PdkFAU7BcUkVPlW0dIq3xHTYkgwdzWXFwux2YtcjlXYRzBd6BC+csJr5mGsriF2tw3zvPfwBfLKvXt7AhIUpXlJ+yKE6ArtosExQcAho6CIcY+QTvrskF9aocw5Ld6AS1XVVkgURZj5qI7Io/PTN95yn4PK3k/RNPUZZL6+FQQJl62AuAiEhufN4YsKBS4cEW7fReITOk8phllWnS5uSDRWf4Gxu0Gs+XVxH002II8JH5eznqQZAwGRbQZn4uXSaOdzZR+XwXjb9CCP6+y9xtU568F0VPRULJGKFB+yp5I7l5xURfgjjs0AeJlRWhhR4A0aucTUZ1FPyPLJBKpYm0GhDWM3pdMBeq2L8y+82W+wpDGbqEJRCwHeipJJ/oiko1zpoHhNEMY8ah2oC58YTnF9tLj+7KiCe0XBEIrhItenKbHxYs93pE6phFBssTpkgACJ8ln22pgFNaqMTShKia+sgGiij/R7FpiFja1hK0dAy2wC1TX5+rgt+6LKogV9D9svEQQui9k4mPhIKgj0c4iysXPwyVZTIXI8IhnqS8/YjeMHwBdpVhKz/ZCaXWDBpDK8ng5Gr2yqulsNCpV9RA7tenqiMvWDlGAetkQH1ez66+mSLyi+IlYWCxEJFg5iDj092Ymfl7UB1Nc+Y9jHgsKdJLu9DCkcr1B38xCT6q53rHFKnWJjOiMDjrFxOIxYgPS3r/S5pNAYxjeMdIwtcbji9oQkIarQodZMJh6sWDsGw/n0AiMPMghU/JfAgIZ8MU4Jqhq/zMEp+yB7l4OiiGWuHZqQi4CH1hiud5gZi3kUB5inHKjRHk3KzdIYbVi5+qcEXI0AppnxOZOc8Wzbh6HwtWnIOiIwrJbsR0ycKpOE9J7OYdBy/FaFsfW06FaN27af4EFzgKpJdg2UjwV2W02okEf/vO0cgAhPfVT+Zg83Brgny2zqJ4d4SZoPqCqyJhPCGcWCKkzeAVVy4efnMZpiaM1epEXqrkK5OOwMrhLOsQ39xcvYSKgyE3eu/aWSDPp1jh9X9NypRFUXPGOFezsK0uS1HWJ/BozwZvdA2AIpW7guektqqv6gXmKwtXDjbnGHpb+FhspXAoEqIY+HiOJRT0GwYkL7rlX32HUxxOMDrh62bCwtllVLB3eXJC/EHq4wFnHRkckstDNPDaDhBvPO5MYJEjO9pVHWENij65/SfmVw+rBlfGlceAVYy1Up3R0ZCh2qOgaG2Osnx894k58D/5rz1e6ZAg+DdFNh366bA4k9zsKeLrAzc1SeV4gir65kw/x95dDuYdFq26eUpJWN9pah+gnSyRHVFcR7Q5oHB6dOkGdLkHO4R0ZaM4FVuJx4lPKlSiBNQ3cT2Y0O7eso0Zs4SndoZaaABFzhB2jgj6FeQGQ1+vAa4hTt1+9pQ+yMBGEvicrNxt6TfEwFpHPYxXkGrvcbg+pkxh3aqrjga6rRhN/pb3ZeWudzQ9LH1dHONW8svTmcnNBdoF/YkXkYstcQemgl+wRTAI7Y4TvdDtyoDqnZbKbniNYY4YRatKkb7aG/q+McWCqy+i5REfLZFtsipcVFUNFJMXUqzE7urdRXYLJy3I7Xvfy22wBNfVuvigdKRgwO2Tsqm53MZe0EtA4OyPv9rqmI9KBdZE/Ru99s2M2cq5CjVgz2ZmUJN2j0g6pRZH70pxQtl/Ka2Ck7/PBS5kU3IbjV1k4bZie64hH0t1N41xC6SN18MXRheCCWkMIcJcYrf75a5/sh9gDaGgqvUwbmEjKCRq5MBVBGm3lvyY6H+YFTJDjRqhhY1JsuYZ7wTqbvcAbtFiaEX089jBI7zCDUTcC5xGAf6R0gDon2koXqor+hLRix9D5o53NWHzZ+g6otJmvMIrZsDMZDIYZrfRTpZT8TrKe2ycCFzBtAw+j3ecDCdLCfmMdtjzkxN5waS2swvdHeDA3MWRsBhBwuY2o4FxeLFVfljAyiwBQviZm7tftqpdBjDRLiJA6RpQHWnpjMjKBNFOnZVyFebOe7c6fDDMISyD4s7brVJdCqg7528lD73ZEeoYxjv0KETsSXvVlkRDb1bitnrxtkLBrpTkBBu7/6gGxpseSLG5em99cSkLa9+phsonp0/Lqdku1pIzR5kVhs0nb9ZGgRiuuTzA6w0aAWImith1dMjU3tZyT98UXJW4zBpyxDDyzMRmO7zshryKYryiK3s3IOIcBoYnWBEspa256TU2ekCdfkvasGGVT4iPhSdYw8qb1qU1bB0oVmYsL9aIikviOBcKrUp3dtKGXDjMupvnj+5HuEdxMXMT5GwBcutQuxG29TOsx8w+j4sxcNQD2+kIUqC6KI+x1kwV77gnxhIb1oBKr64sowS9EeIjU0YRiEAeGQHa+zkSgZzEX3hYE0oFJxtEov8zOAaKr2HeyK65bo2YfebLo1AiaUFrTmE75Gss2rhAtRLTXxOuR7R3EMQRJLXrwIh/1j1Cl5Pt1iO852xil1sQLMjLAXHKlDuwRqSqvRbqT1+Mo9kIUbLWCBD/ZVgL3tZF+oyJnH4dp2V0ABck27Aj1LhLVE2dSNwR7rRtH7I/RJK4QWlu7mK+9jfHm0GRQ9zoueQvnmvbnos5y4EmMDxzjStf1WS/Zd91JMPXCvpA4KXqU5AfLziM/RZlSpGOgilFdB1FkVPRmKQWy0eSXErzKmMrmKFszFPcyFClrKVQX03DDR8Kga94ikFuIgD+2RDNBTaSRxchZQ9vYJ1aDFWbjS/Hp89gSwdvPHd1SPaDiPMgDh5T3MY21Y5hXUeHly7/L2IP65zt+cKoORXHUXqz7PB/RU9sHtTqU1o7I0a9yhXcxKAXGwVRuB4sLuA/sRsKmgLYctphgC5LrhGSYw5galojm1rSK/FNw4S02ZClDl6ulDq8LE46Nhsh0rQjhtNvODnJD/86gec7HJ1jvItnp0m9P2fMZypY4eD6NJaMwngh9eIdec4B2ub5ldYUUVOz8ueWCcL2ecLXOuQfOT0Pxw6Pi0ugbmDkFcdQECBcUSL4dJONC5GSYUrp6U86NFMU2H9vkI3nbdC2+FQH6x68t1ZVDE5lMf7Q3vrunIX/A8QM8cwKtnVLJKvfb2YNwEezFBGYSlwiTS5bhC8fMPcD66KETm8hKStunER4e0Pz19Qu4rJJcDA9RDyUUTFOyl3xbytEw4qahvU0BS94ZScQEonaSrN0mHTBQiSavaBW3jHvgnwkHk44WhtyXg+DqeLwnxrFFdFwP7wsEllgEmF673OEDP3tM9HPZRpXcSen7oZR8y54o+qS6PQ0vBKnTla7sSDdECV3q3tS2ozUBp3jyXLipaueg6Anq/E6ssDLRUGkppiB7vlNH2jFmpc1/vjAH6WnlHAhsfwmm3QDXCcaDkJlkoLrB6sN0S9aOxE9r5ebqFyDQPmzTQmkvCL4fyyza6Opw8j+RS4rG9WcB6YJa3YYqaOSMvxOYnfXW67oTAf2+8lu1tEv3iDG4c7QCyJV97EC+Tx7l1J73Jz0AbphHraHPm+mnO1jZNfYd2LT5RhDyUu0SGHntmyJmykL+W83ExPTFnUi6Dsac8brv8nYuHgx5AbR23B2cN9DRSvn2PtsiTPMYEEBJIS7+cCbGkc8FunzznYOsZou+TijXrDkW6/y3vbvjPuX/CuSOKSiLla81ZuZ8v0HDRn30lnUtnsh+C8Ge9CAj/+y8CIn/0HqCf9Rqgj1dYffEeILBOfQKbhKqZ4u9eCgRnOn79NqWvX530/j6lH7xiKXh/5U+VpOMP3gT0KOIY3eSHL2/6+vVOaVOPP+1VTdQ3GvqQ0Bcawn/0qibiZ6mI/l5DrvOb+mmboh63Iex4+D8clID9soPfCOjoV2L3TcO3x8zXDfj3R6iPrxu+PWa+bsC/7R7/5v74twP8ouG7o6+6x765P/bFAOH/JN9MY1XU0Do/3ryG7Cfrg7hIPtvQu4F+YdM/skFkde/vfsOJj+N3waNeP16X9Xhm6DVzvwbLQP2K3oTVbrc8RNvLsr7/9r/hx/+OkDL/O6jG330X2Q+86jdeqPW1rw1wJkWdaduRSKLRxsGQ/484FP2Xe/UZ8Z1DXfawJ3oTfgghIZ2hT85rGJPHn/emOSjYOEjYNPqRiumITcL0G6v6wdvz/mH5E9++eo79Xv4E9qN4hv8s+ZPfyV9sHkFRwzatyYroXxP5nyGzb7M0973McPwHMvtpJkt9JzI9GYbtFXjYKYmSAk7u3y426lux/cDUfig2HPtZctv9jtycBErnrye13R81tp8nNfx7sX0nJig8gF5dCo+iKhiGIvom8v1GSkmexei9Cxd9vqHPKGO/HYrPL74TXx+p9dtwSYnovy/TGLonVEL/8r48+LJ3dPy5++3oo//fVOPQTH2U/F4u+Xjfa9Bnyfh7J74rK4m/epfr92bxhdp3P9D6R1ufVMEI3f6r4f7IEt7vYCFc+Nnq6G/SAk3vvu7ibebvV335wtZvOmK/ze/ENx29Sea7jjbD/DTtf8FWv4fHH6kcmeBXRkt3U/Pxxd+GDcoBeAJOtM/PX27v6f0wtM+NJBWj/75seoMJH3eDGKv+aDOasUiLZEBiQI/PY3HSTcmEGr5FGR8tqK70RQ8BejnvR6j6nWve5vijkfxg3u/zQnPus/C/GKg5BJjhz68+/h/0eRPE3xHU+2136FTlfLacrRMJ/lQd0/h0ICmHEzpAIP2Hk/33KOwPyPPbmAd9d3v57deh7gNsRzB6wITyO9S2T+AkgvATZfiarIm/Eza/hoV/6FW73we13w/232asT++rfh/wL1++EfpHmexv2K8YQbNfhYMPsP3PhquPU5o0HZKfE0CYn5jsPhLSp5TzlpCYv5eQ/mjCe0+mH5+/7P03k+m/kOywP5rs8L9UsmO4r3MUy/6TyY7mvs2a30Cwn53sfvQm9P/Xk90/nl7+E9m/jmz/n0b2j3D1ZeXUOsCGA9Jmijjdv5v87ci/T5mZ/9HizKc5/HWF9n2N/t8vte9rin/BAg2OE3+5Cg3xfTXwL1ei+U5uf4EaDfF9SdBQnF8vx59UyP4mS/3jhe0/Qw/EH3D8/+FiNvF9rewvVs3GyW+9/t9eziZ+u2iT459xZf23dKoj9FtmgmqLo91U9MkjQcjqM7D7fMVHY/sD+Pdd21R921IVnzwmid7WwKPm8ZhqiPi2X3UDj/MAAqfqi34/X/SDbkYIiIM+LoYk3rT4qpoAfUqb/hGM/1wvn4DyP9YLmIMCQsuiKkZkG/815tsaP7AQn8uTBsr1/8BzgwcywDoc2q/F933XsPF7Ef4dyX/jCf/8XoL3BU3YsPt8dEZLn+LfiN9yqwb6TFptLD6HuD2pf/l2Y8EfwN6/BeO/WXH9E/yW/gYbfsDZL7yW/mHO+Wk7EYgflUq+c1sF8TSxyIpx81qrL+qoaKvkJ/us2SbIQzew+oc8QkmCasyjYHP0dxL52bmGd6/7g53BWYvvIDnrP6KFFYwQM9eIGf8NHU5hVQw5wjVTOER9ESb/8bC/kofhzL/fxX67wvPZZ/bVRxLExgYpfGrbBv0SH+wtqgchVMRWH3l3m48LIe2PknacNsd8vP0exM+29gPL+gl+ivZabJWot11Mjo12ZTqG87b68Nb4xjjlYEyW4PX2hfUac+RUf9QbERJ+u837djYn6dEbgeEnfhre+vz111//44D/Vgf8Bs+z5L/fAX/0ixG/dUAwDNOjRVH+kw/C84sYGuzPdZ73NDVMxVvtbgOBn/z+n4WGqMcPxPtRaP2EdDGUpbBkQDcstrWHr2/aTugdBwP6+R1cbraqx+cTK0SHsDoZl6Yv/+joNp29dbH98sePhYqt4LyNuaybBd3uvfz8dlIFlVFHyNgKdGbcRBMKmsnvZ/T/uPxPdnnyg56/uzy3+7e7/EfQ+V2XF5OoGL50+EdQ/mRnl/ugnqqgf2Ns6A2W726AfDP7fez4uQVmu6KGgQE58fyOGja1Im/4D+P7K/kGjpF/PecgvlMiWiz7qAVC2Jk3GSrQ7D+3fiPjz+dozaYwpPd7Mo6v9+3UwQQ96iur+GqV+7cU8NuL5L+pi7+7xE3+6fu5/vD62L+mpu+XbZJfs18R2m2a8UdLsduyqpsnWwaNm7fg8vrl828pf49zv5BokXVbXf3GDj42vE+PCkRj8+X66G/+iugfb1f/QvM/3LD/D/vsP7SF/rtV3m8MTpJI+OeXP39vNosTv+6+cnjyB0+b/Oh5oJ9WmSW/X4753v+rqmiH5Ddi7pfL59/+Wu8/Lfb+U/r9E3kL+3WcpqhPivxyJeKjzv7VSgS3+1mq+35FSHyDws6r/n4Z4l9/kOvv74r4+49y/Qs28CfokeG+dcAfa/KHi8k/zQXp/2Tcf2lT2b+8W+xr5yZ332j6j+4W27HE17b1rev/ebvFxETlLpfTcSxnPNkLRmnIzN++hwTf05qPlj+6fQzq+AdbgD9WnrFPfOLtJ2wRG1RN+Kou/rubl3+LSv1gaAgn/O3DJdHgkJvuvh+dAs9ABQFIf5DM4mRAYeorRvVpAm8lhLdBf1QbYPT89cfs5s8YnRYMSPKPJka76OIPMSO7ldAmfWlTIsbCLygK/SD+dzKtb2LK258/izd95Wc49oM6PsXR38dw7h+P4ejFBg1S4GdPRc9z6lsxndz/Xw==7b3XsuNGsjX8NIr4/wtNwBHmsuABwhIkAeLmBDxAgPCGxNN/Vdy7JbWZ0TjN6EScLXVvEoSpyspcuVZWsfonUng8lTHqS7NLs+YnAkufP5HiTwRBkiQBf6Ejr48jOIezH0eKsUo/j/16wKv27PMg9nl0qdJs+urEueuaueq/Pph0bZsl81fHonHstq9Py7vm66f2UZF9d8BLoub7o36VzuXnUfpA/fqBmlVF+eXROM19fPKIvpz92ZWpjNJu+80hUvqJFMaumz9ePZ5C1iDzfTEMawQTwY1pbCmHsWqOxRCTP3/cTP5HLvmlD2PWzv/eW38O7xo1y6fBPvs6v75YcOyWNs3QTbCfSH4rqznz+ihBn27QaeCxcn408B0OX+ZV0whd043wfdu18CR+mseuzr4c/Ikg4X8yaiL/d3brs/trNs7Z8zej+tlNJese2Ty+4Clf3PbLiH06LYV9vt9+9QCK/TxW/mbwSfrzYPTpdcUv9/7VsPDFp23/ATuTv29n6F49elk93i7No/5W0JGNKM4ap5uquepa+HnczXP3gCc06AM+SuriPUS/sXD+/vnNPUBTFejauUPjFU39R6jl1RMNLP9+JPhyFPtyBL5Oozn6iQQfbwm5b4ufCKG68vZpw45K0QH4Y3mXUroU8BX6AzQggBv8LWKZdabREV61BO/qagIotByUdYUOgmbz5GZH1+MbPDtNeLeq4ux9QYOdriV2IbhHqqZl8riAlExJ49EsEWndbwHfGA/uFR7WxeFhGwQAvMuJv6pVwqQ4uQj4tc1y2F7uZbSGVh214uYSt+GWYPStiDBBnwWriFzNKxb51dykOhBXK0znCPokjzOnEb9lW24vzg3eJLBv0S1dmrw5KbIQR244E0yEi5dKgWev+KmEJ90E6/DaIHTIB5/Jr/AD54beJTZ7WPZDZjxSLmr8eb4aZ0uqvN54TlKJg7o6W21xBfXgmr4Lai8ZXP4ieD14hkA46a8eyK5+rEW5BrrUHmvwf+f+bz+3vJTNGqQYXzXtA7uote4FV+umgoVVjMLJ5HBWXn0iu5Zu9oaWYYChjE1eQEfpondq9kIroqj3/IxzbOhjIOcHTw6Q7wnmc2TN6nXRwQJ6u9HCGMsTEG/mwtDrLMkTxtaRj/lAqs9NMtIgdiFiyE9lgi6LBfeVaJSCnWG7Rq+9orbj0nGZuQLolB6LPT8yECNlq67r0S3lgtePzHFrbQ6jrCu8h6zkxzulGP0B17EphLZwZ+81jutNPBdHm99P+SE+JUSHSft69Tx5mgRNafRL0+YSWais2BfV68rSEB15sGNb0xzdZtp4ze9PF7ydhH47rjwTJs86ZVe+tsZ+no51a4Su4s6S0o9OAsiCZkFa0o8Tt5uwUSJf3XxvVjTY73ZszJ624O35eTNUYFUruxy7UAxoeIxtPE+fLoJm1zZs0wofZ6ognSxacdLMzAffnICuAU/xOZMEXiIcN/gA10X5geB7/WE5d5sXOQcrTjdwPJt9bGRSUBisEhbPV5Ns4wzPHW7wPvLxcWma9UwuZP48buzBXZLndcNCeDfe57SjD4R6P3ovsqCodr0d4IWvw3md9yG8e1ScjEFtuKZcgF6iH9NMy1yHUG/eFGpf7PNaXOBbmxIHr5kkQTOJw9WCHkEzpfMMbvktQk5Tu7uyPstyzs4zcH2gNDZqc93GwdrYvOOnkPLx14XHzzbq4gDHA/Ba8oqu3CmC3gBBU/aQHzJle9Feq3V0ePyiBs3oatBD+vMAe9DvRGgw58IgBIq5Y2d4WUcJlHhJYMutzcrEcLMZnoj5jofgrhYMDZ3d6jZ0zuV9zjUTTmfgTPITX+J05VxRLoQ+xPUw3rQEhBvvCIDaezwF5NbbnVff06m2O6F+IjNf6oFmbV7dhLwPFtFhhQhZUT/r4DgMojawK3e5Do/qRhe0a8ERTgvTBummMk86Djtl4ddwPFFGegJX/eS4LSU6QhHeX6zmDSoOEuSyPaCjh1rkWztvMLJ47RybXUtR8EOZz9hHHsov2QaJv12GdIGtBvRRD3CBkkmwpUqIP2qC4jlkgR6X+EujptCx5KJnIseiKZF7rOr2GAr2MicH8OJxTxjHAye9gQClnX3UpfyJQV/hBZZq7/A3hzOulQjMxo4FdRk/rnPhdSFnous0eI4NrzPPKPoJTg7O2xXPY7BTIGXWU3wmWmVj+OTSZwehBsfGHGkleqB4f+VbmncMvIPM+nZerLY+MOJKgQHX4YXjaMpajgxTAqEXj/BV1YJHAs4FbZnuclcc+tFZg5LCe0EaxQclCkqUh8PAO/bqYeNr5dY1+eUMwx/Fci6Qu5lDIiJva1GxUhgUYhJvqCMOdpDnZJm2uuQo9wUsYPcjzxkZi9jA3S4mCf6OSjTux/NdfOw55HY8syL3nevcoszp8oCvKWkA2An2UMUCJpm5vRstc8t9THnHuczaVQ+9phOLZPLZyCdBwY1PiBKvXqnJFiiuPggQ053+SVItZFnhDUYmr+wtZXD3G4maM1vF1QcoftIb5H5X1FMB9dTnZ3ElV6+gXqOLb5odY5mgXlzZu2QYHwB8Wm6nNSPqXW3Cc1Wd8apiea+03Vc7uK+1NzYnQ8yFlXLKXk7FBzYJFnPEthNyJgBtPvcHRTk2aw7cYryAAaKfDJuy6aIGAvURBV0fcDFWRqCPO5JV48g/dX3EUsAzF/ikMb9aUc4TY9FnLo7GH97gdAdxLpiHzTZExhzhETKOHh1zd3TkB7ZrFsZFej9rggHPq5hxuDWDaXSbDXGU15Fh8Md1pICNTXvoze2mX+RBuloxA46JOL6MvHj34coK7ygphwIMUZUJ627xIjNECQ61HW8kCGL4V9+Qm6QpODguKwOz6VJ4C9+xJcOCnPLsc7ESoEHOx40+SoEHkEBw3YQ6RVn6lG8ipJObHfNJPFanRRRrNA4p5mQOLTkLBYgy0Z29KveDRT9PNYg8oh8RUJ5e6yBuEvJ9lG3EYmVub2w0KHNvTb7OCTp93PoB35Djt7Mb7wWvobhcNrsFA6vOxWBjeHBC7skRvUgS6UEn+JkR2f528UbyIWrCq5l6msPP/mGTA+DlFWUgwCaJJ8zki2gym08o1LElV3Pra+HSRAm4Cj7KbnwP1QtmJBbEVBSZIs203Jlyij7Pt8dsJ5KjNMf2xeH13QSvuhtoHPf8kEVjjWC/iM93+LjIKW7vJ2mEuhzbjxiRrrK7tmgoWu+A3yEBej8TYcfZGtDVqJm3vTm/VmPYFJeorO0V4tYRRfm5JuXQly9Wu0k38eFX46jeBNJwnitqKqDy3I0p0f7A4tOTooptSZGhoqS5m5Tce7aSAEtPPR8NbP5aO4zSuU4kD0/Ufj6779l9CwJe8cMnRyIcqHHrRAaZgh8cwQZ2sYTGMWugamOvNt/s+XrWSIl56udqGJybEuy54Pmb2oJbbrJ67wnoifznE2G3+pp45CzDOjzM79CtzlQWCIq1q8wellndjYPykVPvp10tojqoZ+hzwaxXhxUZ6Kbd79aqzg+Tn1F0LG0xDAMBIkpMQcV8Qr5Q7kFJeayA9yGyFk8fwwCHSfo5C9zMnOokfB5S6Q6xhyxk84YY0eYwYEqEobNnA5IEPrkD53o8LtN8aRAASnmpEhMeq86r1cjzvd0uLd98DGyAIrqMEjfsonATLwLKznTC3dcEskCYJrI4vSY8JBaS305SLuk1D4OMMpC4Muv9BAFSdnvaf5GQ6nGpgi2czIkAxb5/vad3EJG8n90O6dsTgmZG+bmrLQ2BJTALseshSj5mE70VUJjDP1sgv9h2LeoDypoSJ4kFRHMriu9sS4H25EYnPdlC0KIx7a0LXieCsyGmdb858DmjSlSKi9xA0vfyJAA5xO8xBT1CPdWp23dYsymD9GYbR1ZWi3aSua2V91SYXyv03uVeDG71gMleycHcSCG+OO1mMFbm3FVaEL3eKTT3KHu+lxcXa5yzkk0er5AJqPqDHZi0xRQiyTtEmbrkU0pPencJN3C5IUaxq/MGG8q7hHiKWKOETLMkcPN6vvj4cdmO5fDiaLKAMc/XqEO7RFeiq6+FXTwuMxo71ygWVgiSXFyrerq/Q1AuOTHdTlzhIYxiaR62EyYIS4SZE3B2mGPOprKKWkbpiczd8gahXl5GA4sxK3to4+CnUtlLyO+Eh9EH+ESBvbikashJdOA6E0Ztyi1fs57WaVnle9zxA1qCWecwPlk77jRLR6yAPxZ9BRllasMcZDt3Fjb+2p0Ro6BNJ/1IYSTR7fiLmBKevl9bIJ6eQ/TKz+qqws/pc2dYQxdRCveY1WL+ZE81iqPbAa8RC2Emb93MjNBe6Ipnl57kboEWxkyY61uMZ54fD8Jo4RnNEqdCJmPch2te7CjY7pPIFFaq8AfFfz1rMF4LyClzl1wRrzD3wWgWl7hZHBqb/qrxl6u8nuP1tXu4p8h+BU57cL/l3vOVHl9QoYHXIUa976FuEVhl7Qgxf3KcFRgbouZgQO7ZUi4zp2zYw7uR0dyUkcWzfGQ2nn+CxI8LPsISshu/V90+YA2ECplen+oHJSpYiYecdR+0WT7bQLmfLg2yiQVZ55UoSDZQN4bJDWQzH7KptriwR/Eu6vrrXD0GBnnE5V6LnYZDWIFaNIy4khPmCzdgucKw5t4wYEjEHrupdRgLzSbgwYD4OnJW0BYHcd33A58DcsK7RLYuhXy6iBvqNjLqzq5xp9tHEIok66mXU2Sg3km2DbZDm7cHbUC4UL5M1Mnt1po4By8djeyujNqARSYFIh1mpYxFFFAviTV2Hmgcv2VrfRoZSU5Wt2F7uikJfSCgJlvzCMXc+KhlrI3VObWlsHc0JslGvkllAYJ+MAaCDqEqd2VXzWBmH8tNVk/2+fQUA74M0Ujr64lZN3J8HjQkxRPiWJo+sHAeoqo0mqgt6I/T0SzesaZTiFrCc9tomwre9scjnZGuFvkOq0UsYz91KlZd7Xy5BEGjMx7pASWtMq7fwJBarHBFsl2DOJ9OwnUTiWoi7tX4KJUCk2tk4TvyIN64G6IoBuB2WJ+sZcBoM2iU2eaQUD55ISu0JLFCh7HWQ3gH3oSekYa17EL1OY0UqmLJOibChDxCxXNyQ+0UXwLmQnt2UF9Ye+wkeFcf8NiyzZ21O4ifOY1mPscEYantGEm27hPkA8xJg2QR6WtZD50tcbhDE1iblt+LF8TOPr7xXqilIHN5KXWbLhAw8UMj37NMJCZ6nQWTH6HTrfxwg+Ag6rJ9lnw8hrajE2oCYsezWn5GwdOnyE/yTZo3FAv0TbXpyT2dl8KEqjlEHsO3vJK6uLPv/g1bxI3aSPt5AxprPWEm4trL8RSBVccQgdMMxC7sUQj81EYvWeLQ6ZercegGlt4g5x+a+Go0dhw3iSi/eeIZu2VKWJcdfXDIdiqPVQa1cx0pn57JBSYOjy+Ddd39wl0Ton1zUTINumG4E3cXKwuQ3K8DxasnKodN3VDVUXq+x3jZlLUyd/eSTvwB89TahEOm4xd8Xs/OuiJgcHrKfPaJfNhLLuH97fSg6fJiHF661nkbAgqvBvfXkAZ7IqrUZtZaPyAlK95eUJCi1kwHkDPs1bsjoz5uNdRiIhu303pcIiwT2+3UZPxaJRlrZuC0XdWLfpg2sT5cjOh8qbEV32n8ybV9ZETmeZDYx4V/SS5SAXx+iqehQrrvqsN2svqO1HjXgg8ta4H7sjnrHdBlLtfAhAoyKxY68bOOzkZhJZwVlaXzB9IfEFWozu7OzxA4xhYu0CE9CbsiKoJdFA96iYX4RAX7xc18nT0HCEUdODRsT5/3l22BbESKYl6A+dyUmH/kXUdkTdf2pxTcm+emoVoPS5dk8JQQeV3JTYDMifYqojhQge8p7R2JYjn1fcxTLlChAlV6597TPbl7mNoSMEJ3CLN7cWQ1kjikk5opMybYl8a6kFi6KFHZBah0vCIQT0fieZgzmPkJZOthAMBQuD7xgvV2D+P1ziL+swfFbb2hQTMXydycjRYKxKZUdTskK5+z4oYwTUhLRExFDrVNfLetb1bP2dalSB7FA/nLRAQaVO7OmaJLRy9AWDohxy/mYBzSR70r7caYNRiGDkp4ajVNLfcziQbhhDw6VM1H865+bw5CR/dMriT0wLysitEqwBVBzrCjOZT8sCeoTJMg9VYSPEVruyiqZL8coyHvJL1f0dhAAKLLRNi71ur0+YZ1LIzmU5sBoyTyc08AjkrViwHj1wfJ63jlwhgeUpmTn59wGYPsy4d4ARtkCb1wjIr1XTfQmZMSaJsAZfuHWnTITtN7Zy9E836AeQBRZLl/mYglbL6KToEOKcfhenLm47mfNMEEkOen7NkWMLok4soau+fRub9IYmOdU3DVIC+ydHYCjoMmCGIvrhRUjMnjSygGVKR44IqHNg9e5h7aEXuVT/HplqNCoiEBTt4Qs7g/uOKQZE2erzfl3NV2l7d3jwIc0nxvaZkXmg2xv3jABK4UHOJ0OFT3XIGgvbgnzkTe0klmd7WhzurlvG8fqK+963Y8gUOu1fZgJt+1nGKnMI3YxPbU5W6uSkp3TBFOInrDV5RPuv6euGthEKgm8RiA0A/qDTYinNTDlXeMOkR5iWNgvFj8YWZzBjP4jycmuVC+WMiZLYDKRdg1aru5w2DAPorNL0rIrJmNTZQDLpqkybGWqgGwqBh5V07GSwUTNCjHQ4OKGPYaIbIcLyXLEh2kTcVus5JVtLZmRRR+uthYpl4cLgRqh/zRKJRUOnCiaZmCDH0fzJirYmhsb/KpVEtc/GjhSS/uNKq2jhQIxJwWXtEjQsla5T/RHCkO+VDzM8yFiSg+VmW7FzTEL7nVlE7m7Oe7wmThOIrGJVccjtiPKXmTmo47FE9wft9/paQMp292UFaPG6ZILkSnK47SFcxq2qqp8rDeziaackOxYHct1Fk0hb+jWHXOpY+iO2bP/Mqiwj3CbISmhUtXieEi7KynFkpCw+hUfeiDTcmr7Z1hDaI4FszYOawRv+8MM6yK6r2yXKTtxdy5g/ywjKK0Tfu0WM1FZTZOTq/c2/leGyDPWUXmT1TbjFElhld5iF4ixAA+ao4QXWAegra/wUw8NXlgb+pSJcT98ZG54fmmqAtH+tEC96XtkVTbHhbguA3bXsK2G1nqxWuPQsaosDs7P1+jI6S85wNAH6G+AChvgtcskQir2QnhR5EBEfm12dl+6AqsBrMMfNJkCphMjJGYMxeZppO3jAC9ULlEGe71Uz5Er1KGbARD8waAFXOdDtrXwlcvU+yPRiQFpRWCaH4JB+rcAf/lQuIAlUIemWRAw+zn8TQkawGAERZy4lGFPPbVXyEbKwzXydh7EO7IUEFjQaqhFea0SjyRK65yvp2YkihPBgApwjhyEuOONLunmaHIoimwrc9VOxgH+lJNY+oyV17u+MW+ccQFdbU5+HFRcFJZGKnEc7zUhsvxtVriLFN+oR00qNwulCF2KhifV+TEjqOB46NTC4l17kdRf1ArZZ2RDywCBtTF4t5VsGHjkJdbSC7F5yPU+lxH2Z1Pv4B6FhWkGK4IOYxHgxgkzyl8Xcqp+y5Sh1JoidcYOx+ixmYEUALoA/4yv5PZsebSxUGzHxLDH1JQWrun9INOwWA10tumaBYdp6vkHBZ3oGALUV1+TiD0qcfn8RRfQ3gNXm2X4iKMI6oTn+zUfViBFal8nEAyTGlQ4gTFxlxezhsqDNmnSFQnWfIuZc22k0xBx1zpHcicDe8Zo3uW8J6ojt5sslNNdPnG/ewOQvirAjBuliGcYZ5+TRby57tLFwwcOR4xU3DY4vZilfFwiV/7AQVxn+U47VdZU2KC6ou8JyoDxP9UKJ2AOCgFPX1whjKl4oerlGkh+byZujMO8231vFohA2iIWLjQGdUOQUXWIJO5UCu0lkZr4iBTcieGNsURycY0dKq/awFvFSJO4kgxZgeV6xmDvDhwtWesgR1qSRgBYomdIaktFtIhnDfH0l/pxBUBxFZcmHDxZKXabeoNIxOdO3IBK8T3AU1IwNHGlwpUz4lvzevamdXCq7f1WTsjYl3l3Qbmbj9Icds5IvpUa5GEUudxwfUA+QzMCvLkiiaqcZI3nkTwkbqDsvDmvZOMlTyrMdZB9rR3tdmVxbWw5oOxbA+VXYMe1c9iH6ZN2XP4kjo/LkqaHtWWt886uPPNEG05P26L3aUC6UZTVdD8m01H85bwT5gTMBtyw2O4ijl1QBMk9Lj79w1jEYbcF/60+eie3HSArH5WYbaZ7DNvXbxx1ChR/WjtIlCI4p0nO9H7gGbMeyKHmGAS/msTWh2q6hkqstme5yZCIIZ0dWe+a9REdyWERZprsQ2yOBHuqNKmbjYn8xa/2kDaHRva77rny+dsiFwwLBGXVpF63TBTd7uEHEpoUKa0Wes8qNuKIQKo6HWhXfJDlSphI6wOH1InOPL77SbfxNHuObx2yZa5iGZE8l5CpS3IJynEERNrEdIZ90HqJ1YwFYiEOiuvQkGXrJ6K5IbZxXlrlgLQPcYVqX13iTudg2YrGlQLOyT3Nk2KjiVhnhy1za5hVkeeo8I8uQb+ORHEd0VxS53H0E9TWJg1LRZoZFzXShSYPlDFGEe5IY4KBc3IbFE3JDAW3KsVMWCHRx4BKlOOq0rMiovZn3oxIIbrW+nnj4vN/qLpu6zZ+Af/2ieDD2hsIcU6LSgk5q8LLUD/iZGWSqG01yvhynk26GhrH3h7iaRIjDFX8Z2Kz/1nzU+ElefyCZtfL3B8um8tD+LEkRz3/s7CLveSvN46Iytq+yDp0IqTelw7C2MRKHZX1oC4o96H9hWOKArEieaFyAErEIaPuV19H1R7Me+7X24u8eHh4EmFj4u806nfDzwFVdXxHLry7ZFavcUSbly4n8kX4RuFq27rCGHBwCzReI8rl3PP5owYPbInVLfIS65v1chtN7tKXsWShx3R2Y0lJBMwZxJk+EM56RgKWR/dk5KtDrO6sSfS6jEyqNUibLUJW51XzPauOSV091ytweEPIaq3ybPAbAvMw69EzYs7zOqQ3Bkbr82Mk+dlPHHpJQms9XAQY/4MsbehktPlCt68YFCu0wYgYX28a8ENvnzmYsN43Q7+bX6U17WAY8T7QL80Nep4dGF8DnL8WVKjcjPRDDCi+MaLhD3dkS+E/hMIO1LwG58XljrDv1gd4s1Yh6c7mL1LXlepBtkif0N15XshpyrE3jaHIlKMaOPNVwqxoGF2a46YShnUefVvvK95CORyJoCC44NB9jYcFk4pWZBvWn/tj/rLZLwMkpRpHyZ+ZtULsdKD8p4wU0s4HGmQFoJ5N18lE3d5dntG4H4pG+Yi3fw+WQwHZ5yYvyJLMc+3J0AWrxvhpt4WYGxH6TUz4Rv/l1AT5AZNrWWy3vB+LwArpM6YyPoFwNFKkHV8l9KCICiZlYOiXbgui3lClSwtdjGot6yF4glsW/u0QarZV7ePGTmRXD2YTW6kqxkhBTEF9/wzm2fKAYNYZtOQxUIS8XKFYdA3TT35kYs/ZJIUqvkKOSN9+NDtZkKM8Vp/ZGL1TqKQp2inDK75LJWzWPiFemUEtIhNpnlxaEVuPXVIjJ1AqEFRlVOSf1IiFw4gpCOjBEipHugdFWxh/KrWwl9DtdwEm98jS6zkeTOs8yXGNrtzib3QN6RNczd+UOWYkQaKt/SdUc4xQATa2gUoDtZHPxgR9EfJmjZh0sMAv1FygNiGTgZlGwr3gicqF6uMRHzzHujnxCheLEtqH0EoHpfZKqS5xxnrGDELBsdbSPfeOMUXnVWcSKWf3ltanggjCUXMJncrpm3X2XQsL6kjh9OsgQBymzdWRjIg1taWV72Id3k0ku+cAy3vWYkQdLVTYKg4g2MHBXlIDvnUGBZyt4YWIcHR3D+V86eKuWRSX/OPWr5WLLs1hho67Ip6WRBVcK0bMq1wFypAsCBViBOYQmWDf54qFsz67Mf8LXV7Ehs+UQV/o0q2CeqJiFx9ETik6GH2LW12oMERokWpbiPCiuEc02asOAQrBYihz+s4xeyRgcl/uMd6wvejkUlrwU8Sh8mwf6cEMlfbgEpwiofrZjG8ErqHwfUpAY62U4r0URz0YBdNyABzEZU822tJp5yROOidbBWmz19SF01EehA/UcnUoSD1sNNT2KD0H2eVi5ZqyPx7UQHUh7UQ9ArknsFJS90jOwnAeOVpbx6jK4FUhGM+kYK1SdAlkoXJj4ew17M8X6+YoFxv4F3blCjtjlB9CjYZIgJTpubzkJ3WqepEd2WKJVF5XHicfFDmaNYJpXz+MTPj6EK2nqgW9K7LuzihtMWNkw8H8KBNDs09j3Z9XuB9wGYvoIRRiojQO1NPJ7oFIpvD3B9djgRIaEvsFSbWb2/LCnmhT/KMLOs/D+OJAiTs4q2F+i7b1LTH8dN77pjX3zOKgacF9gVDlQ9+UZ0XnhdYGtDG5a7lAWZTR1S5kh+N4AhJceERW2aPj0ndXuzxjPpOvvnV9lamCmebJcdK5261tQupFQl4Yp78ai4XvjOIPWGQ/ogvRdus1Hy6m1mLcBrexxLU1bv4eEJ6mmriHID4mLv3jUMeNbojYlgKVEKQo+AbqmvcXaw4T8K8he37Chy85/KvW9C6dprSMkrwQ1k7+cYmYN2edjdhwOdMiDAg1e7Jrl54IEHWi5TPhWHkXDhAPDKw20sl2XTH75fcmi6c/wiXuJjBcVm4QrbvCX3fnfkIdS6ZRiOMbIbTRMjRe/VKRdUwbLQuHpTTImzirJdOjPyr5zAth8nlCbwEjJXvr87H+gz47Pq2HAwgIEYmrcJGl/u4su/Zg7WThPV61zLyPQHaOEkK6NFhAKoOG3DMjcWbIOd8cs1wlh/90rPEjX07eSTNQp9srzqEaeA49AzkQtqWblcAiYV+oiLINFKOxVEVgLUnt7aydwWL5jlVEHEijQH/7oewCDVUFTh0s8C1uYu+ohmE6nFjA3BFGlydJUO8yiencql3xEQDsPhFH+URqS6yD8ATzR4+Cil41/uHEod6E5IO20SVjkdfCPVUUqA9k/o5JF5wzKzDervtyrsvgjPrNao73lmqGm/ITzE6neKiA8wN6cy5vs47sgjYMU99GOHDgRDLCmN02ehmvXe7HPr70pgvGSrAJr+4EBkxcQ2E52E9UcYIcQnyPC0JgA85wYH3Nyej++EOSTTk+KiYakajvPB36qb6BrIIylW3zL0uwg2yuIXRB4EsE0DZBIiwCFcQ7F/vAC3PuW/U02NxKoOeaqu8lfJh/zhHjZPqqcTFI+jFI9EQ95k/7Ut+Wm9oxhTxGOASD9LpY5o2YaaS4Yj5RsYZHcAkhlvQyklMXmzvrZdgBBxN/EsBEwhQVvZ8QLlCN+xkFnYW5Rfy2T9vF9a+DEaTBW/JNERsLt63Y1Yl2N2A0LPCFhlg4d9lQxkfqoESjK62sKe+AAZiEGxFe94AkFxeP0nypdbjvkF5AequBw+bB0SgUJKI6kgWZBIdC4HIU+iOr50Y+RZqIoldH3WYFkdbYOmnsXcBQuwYG5kQiSnsYbDiWtapBllgEov5drR54SnTgyWiOX/nPEqOXabAFTNEKbcE+jLH3hrEj8RUq95XjWhGYaJKI7WHtxa45d2CU1dkB57TMu41zc+BBI+lcNXzDTlYj4i8ZGkvR78Tl5j3OzmRCyeWV+bQXPtttWiHx6EvL0MXis+1kGMXYlbPJcRtGGbIFZBfQLUUn6/WbeUP1K292Fy9kt5K4f1zcnKsp5JMxVE20dPCyJQnUe24cU7RNOo2wbYRnJBRAb3Swj7cL0uMZQhBVVqD/Glg70Ujna02XcBpP98pPzi5+dAZuwlyKhLXQ6ygDMM2kRmL63viR9Vx0R90K0GZ+BZmM/tGViSoigPC1v46FgInw+Dnq/K9nFE+QoUzSycH1Ssz1dMcCCwgElNOUUbE2KDkQNUYA9oaxTu09oOoSLRmgAlguz2pJ2KoJeCA8xwiD7VQo7WECW90rd1daUp+QavnqW+WO6sGNc2UsjFUkH461ehLrUeA93rY3Sms2MsjtG6XitXIcNC6yBjNsapDPrYH6HkoDqB5dMeWIXv3lS+ssknXrYwEHVkCwYzECWtgNJOIptDoHq1Ugyr+XZ/IUpchX/gHX1dKvLqXG+L8SB0brmRgvcwlO+Sy5Onl6mBhWvEMmSwuTsjY4VKE951FtsYebgq1+UHxN9UU5KHZEYJwFxVEb+aaLBAdoPL01YeYI8AVKfveqZYGhWBBGfcePsJbgP0l/roMKQ667WH0HMkXMbSHw/U1DAu8A230thh7/aixklq+ixYMiSZdeLB2mGMucSLuo9AZ5E3au87GAj89ujDLGIlMfjkf694zojX9FJ9oZWNcQHYGH/l8pafrUHe4dXLOG33SU7wvAOg3lawAXZJjnb1NhVX89laSkM15ckFctTwGx+lc+9cOSuOrlxxRFQj5ttQiOxuoWzJav27D3ho9Wh2CmjxjcVvzcZ8kwgnhxVCaMPJucvci7i58oLKIAlSmB+RrVoK+dWRpFaR3x2GcET5dgoL3lICLbUFGxS8Ph5qiMMmha56iDEXYAfjUicbuhxLXaek99oe2UGHI89edRoLBRWt872107ixODg+ST0VQszqZI84Sjp1N8NwchvdT18HJ9wpIaInT9JFvhXY7zr1qXo9BfJMZP33KPnhjGsOadEfTK9mK7VB8ZDRUlqTIZuRYTb9c0TsdPqOp4TOYzSIE4/UxbbPMwZumRc1hQ6Ni5QidqkCvjtHqdJKMnS6w2z5aA+lxg/uMzjXywm5TLFSfP06hSLDivRAWiIRVnKDVPxtooQ7sq0MBc2HgVyQSHvl+emCqmAgqBbnQeR6gL+SAytEc/7qcluVlwgzPnLS8K7oCio5EuG8Palxi8YByqJ6ftOAU7MGdUs8v+ZwS5Dse7rS9WzNaYTBTma0uPs6dEzZDdYf3tAoGJfSTTl1u0HDaq7LHCauUam8LiVLTcn/OOVrb7pQPNLutlrigkJeJtUhkB0VXeItZEmk/bqA3dORGjvjwr7iTsA8rJ6G5XW7lASU7R4sPUB2kxabpHhjKDRgQzu4we929RawgTkUtHJtV74kw4WeUn8RzwUDdTkBvrPajC5+ASKei3jPjSDdL7EN9TyZJdFARJqHKt46mVvmBWDZLhrmru7xYyM1e5HZs4jGB6cKE9JUTXisPsbqF3N217LMU4BvUlZJ/ewISVLV9yccqhuwKraLBMUHAoaKgiFlCTCd/DigunVjhvB7vwKVpWicmqiouQlRH5NH5+Yduua9R40pLsixjAZVv6ERRxhU7IC4CYeBlp/2mQoELR8TbD4n4hMFTi3FRNKeLHxOdM14gdndINb8u/qMrJhQx8eNyNrO8YCAhcu3oTLx8Gq18btzjNlkfix6UeV+D17x7ZzNajqqZizUaIjWE6qnmzjUnNwn+SFM7AkFh1Q5GVHiHWi4z7Vk0M7J+MpEutnZUGdM8LPmiwaj1cf6Fd9KblnR2lzQItRDhbSiF5I/IOuqVjqrXAmnMozWBvvGZ5VXXR4+bz4GquFcSTbEYb0p7WjIXryR+IE1KJ4TqjdUxAwQolM9K0KcsaFFlbEEoJb6KCrKJMTHvRWRXLrbHvZIAo3AJVNfk2+N72hdVFh0Ye5i0JZC4bHbnYeIjayDRLyHLxs7RL76aC4kXENnUXkbG7bwwAqFIs7JYSFNuD5EDk8r0enoYvbO57r9rUKjsI3Jo1dMTlakfpAzzsCN6qGY3Xk+XckP4ilRZKiQk2DjIOcz85GZhWbcW0F9SwbCPDcGdrPijAiUcr1J3W0tJ/TB6zrzkXvVWOjMDjqmlaTMWofGTGnOtKdSG+YMjHVNHnK64uyBLiDoMqL1QiAcrtp7FcCG9QeRBDoGK/zKYUJMv1ilDVeOXPXn1GBWfdlBVsS4NrYu5BARojuF6h5mxWmdxgnnKgyM6ok75RZ6iBSvX8JSBqxXBNDM/V5Kznn3/sFS+tS3FRAKGNTJpxsSlsUko7+kSgpYX9jrE1tNmOi3uumGGR50GhyY7dNA+Dji8a0KVMofn98oByPD0RxO+/eHGAP/k2F375JAuycsJVUX2bEE8o9pQhSnQUCUXvn4ekyWF3hwkRqIfGpSL48HiGuGcujBegoJNhKawce86XhrZol/n9HFF3zuVqeaCF6xw72dBXoJ+QOrPgghvDw/ELdBgC9fN7ElDNx/US4zeR7j5sPnTNt5iS+cbgUCVkMdDRHg0UpCs2NC/W90/DkMK+fiE69o7G8ub47Sy5h+y7IXUwxViEZccmcJBK/P0CDputB5sbl6gMDN7GmUNgT364SkfdwantXfGl+eNV4GzXdXaP5CxMKCqY2SJo3nywoK3+TUKryXfHJ45CDAoN1X247o1ktnjKkh0hZ2Zo/q8QhZ59WUX5u8rh1YPi07fP+Usb1y0tgzVT9CYHFFdQbx3pK15OHWCOluGmsM/MvJeCKzKlcSjVi5NBmUaeprIvuWQ1CaZ2aVJfkAzMxUU4Co/QQd/TO0GEqczp9eU5mi9/khb4mBliHvJVGl3rkTyKRWRnnZIywJc7zcG1cmsO/RVccP33KCJcDA//Lx2zueOpI+7Z5xb3tpGezp5sb/BuHATW5uK1xQHaCb7BFMAjtThp9yO/KSNqdXulueM5hhhh3q0qButob/r8xo5OvL6IdMR83lPtiVqj4ugobNqGXSYnfxbbe7AZpW0n6+S8rHaAHX9PV+VTpSCFBzyd1Q2O9nbPghoHhyQ9/vd0JHoQavInmJwv7nwNmupQ43aMNiHl2XcZNAPqkXI/BhPOZou5Q1xUA+49lLXzbQhuTX2wZhWL7niCYy3U3w3ELvI/XKzTGF6IJWQwxwlpjt/vjrnu5YGAC0NhdepE3OJGcEgd6YBKKOtfLAVR2194BQJTrQuRg71MZYwTwQnO/Q4i/YrG9KvpxknmRsXkOpm4Fxj8B75HSANidaSxfpmfrAtiFjmGPXruWi1t75B1RabtVYR294KJkAQw/UhQnolLATnabxt4EPlDaBjjFIZsTAdSAvzuEmYl3LTqXOMDjM748PRwFr0CUDM4TLnlnfxsVT1dc4qIAvM8ZpYuVsvNTuFvtYgI03iEUUeYf2JKewE+kSVn0Md8sV+vXt3Oi4gLYHqwzGu77oEmnUw90GZ2nA40jO0cRo2SNCJ+CbZVUEUy+j3SvG6QcVikP4CVLT6a4zInhZrvrpxeX5/bRHpunuIKTaqR6ev2yl7f9sIdV4kNpd0/TCbesTixqxwI2y2qI2IeucRVEvnUtKKrD+/KOWNMfhSFOgLC6vVub438wby6YZyyPdZJYcYYLKwpkAJdWu8vyenr2gkfJsPrgZUUPEj4Wvds8qsDZr3YFUsXRk2JtyPlkiaBxIIp0Z/BteOUt+ccTu1lyBcwoAIbuImpscEhMKl9SF3413KhJlvmr1wDZIJ6McX8lB1QxJROkAVHPQvqBc2MkhmoOsvro6LHK0h0mMbwiAEBK04XlcrUwuYi+4bA2VAo+Noll7hVwDZVBt62BU3HTFwtdHuhj0BNrQW9OYTvieKy+uED1kttfAm1HtHcY5BlspBugma9JhNSllPt9RNi4N1yn2sAisjSIInD2iVQEsZLVqNtB9fZQCK+C0LWBACqYmwj5XsG5V46zxd++EKKEDu+VCh7xJhPVF3bWexx7ZTzfEIY9KJoYflh/XKhxhfH10Gocd5M3No33K0N3NVMl9mXOBZR7p1P2rJoe9fqmUE/pU0QcXrNCdAfZ7wJVqMKlcTXcXKLogmq6NvhmKQG72jEvJXBdOZUkcT5qIEc6GKpnJ1AX1vmOh4VI17JHIPeVCA1khG6FtpJHHyNlCP7gnVoMVVuNL8fnyOBHAO65fPqBHJcB5lACIoR5jH+rguGzji9cm/K+8v4p/b8iOoCojkur857XnV0LeyNX841c6KytKsdYd+saoV5MFOa0WqD28fuY+IzQVj096YYglQ68ZnmMCYG7SK4bWujuJS8NMitxUqQpWrZwi9ChOPj44pDqwI6bTdrQ9yQfHPo3rMxSX7QFNc/eh3p/f3M7YrdQx4iCaxtZ4IfnrFXHeCT7h+ZHaVFXXs/LiUkXW+nHG2LrkO9U9G/cNh4NP6HtkHRHH1DQgUNEu6aYug0aUdZxSun9Tzo0eYZkL/fEQfq+6Fj0Igv7nt5bozCLH5PERrw4drPvMXvIzQdw7g1SsqWeVBf3swfoa9mKiO4lplMvly3KH5+Qc4H30EkaWyxaRre+nRI91fvn1CSg2yi8YAXau5ZIGGvYzviTyjEE46urctIKsHU636ANReNrRvS8dMEiNL68YFLeOe+CfiQeTjhaHdEnB8n8+XjHi2qK6LAanycJllgM3F+z2N0Hfv6RE2+6jTBxl9f+jlaoV3Ra90n8ehp2ANuvL1vhI10QFX+vA+tqFjFkrz9rn2UdHKR9cRMPq9VGch0FJ5LOcx+nqngpZnrGpbhuZgTTDW6jsy2PwQTocNDiNsB0pusoNwgTWj9yPRfQx2QSs/T7cYuab2tgaac8n4Tat/e4xutFMQkFxOPN47CzgPzAnenKJlzigKsfVJX71hOCHyP1qv7b2bxLh5k58mB4B8KTQexMvmcW4/yB/2s9CCaaQ6+pK5/tJn591N84BWLT4RQmr1IVNgxJ4ZcqUcFK/1etzsQCyZnCsg9tTH9yp/7xLgYARwtI7vL85a6NtI5ftztESe5DEmgpRA3sbtTIg1XQpu/+Q5D9vPkH2fdKzbDwjp/rfs3fB/5/4bzp0RKolUaHRn9X6+QMdFP1Ijn2tvcR+C8EduDkT+/uZA5I/2BvqjtgaivtsaCOzLmMFDQtMt6Xf7BMF+zl9vuvT1Dkuf2y79YCem6HMXoCbL5x9sDvSo0hQ95Id7PH29C1TetfN/dEcn+ttBO3w3aPiPdnQi/qhRO3w/ar73V8es76p2fjfhwMP/YaME7KcD/ERA7/5CHL458O175usD+Pfv0D2+PvDte+brA/i3t8e/eT7+bQN/c+C7d1/dHvvm+dhvGgj/J/lumZuqhR77Zc835FPFGKVV9qtffTrtb/z8R36JPPFz1zmc+PL+0/Dorl921Xo8C7TD3V+ibaL+gjbM6t+P1JL3nlrff/o/8OX/JGgw/ydq5r+5jdkPIu2v7Lv1dfxNsCdVWxjvdyKJWptGU/lfCzIcw34fGv/D+6bR34XZRYJ3ot9DEkM+SRfolfea5uzx79u6Dpo7jTI2T3408HTCZnH+ja/9vXv0/eOjAofh61Fhvx8VAvsR9uF/1Kgw342K2D2iqoXHjK6okn9tIP44Sx6+sST3vSVx/AeW/MPcm/3OkNKKek1gztgl2TRBn/tz2hL/do/FH3jlD235x3nllxv/rU0WszYFaFdR+C5pommqkm9A4K9g7ncgQInov99C9j9u7qlbxiT7W+d9sow5Govsb97wk0Rm6Ve7oX4/fL8ZncMPBufLsTFrorlav95D9UcD9vkEB/GbX72DoJivnIP8Qte+3OKj559X/XbP0m9udMC+vhH9zX0+DPPdfd7u80uv/wWP+hIXv/Woj9yDHOUr16KHpfvywc/Tm5EAeAJO9M9fP/ySr77cBVKA9ssxq5urvMom1D30JXAszYYlW9CBb9PdlyOoOvKbO6BXWPYBIH/jko+m/6gh/3R3IKslMPV8dhAFfk/yYrpnW+gNIoE/bO2/15J/R0+/hQIYK++9Vb9GgC8kLYFmzMa/JZPGDDYuin+hml+TfPE7gvB37dj6r5K+Lxj4LZL/ssPyZ4N/+u0exj9C+J+xv2AEzX4Vfl+2C/4X4eHnb1NAl+dT9sdE8PfqWkPjmqOE+efMrNjfwfeY/2xi/V7s/smNSHN/Oht+z5m/UD0PkpLsT8rzDsyfj+Z9T5ot1fvL5fgHycJvMPwfl4l/1Nh8VxT77wtDnPtubP43KEPyWy//rwtD4vvCxzd8KfnF036lRIgu5GjV5rcsySmj8REl1fQDZogkJiZ2iLkZWZR+f0KUICVaoa3fsekzorC1ir4/86L983TsS6FueTQgmbvfMq+/ugP+j8tsv3GYHxYav3a5v4OT/UOlv+/443+mSvTNrAb5Aw/+0azGH+fA36O0iP4tAZjuXu33MPCvT2r8Pl///WmN/wA//0cHlv2mZnWg/k4m84cNLPc7GN6Nc9kVXQvjtkPx+B7NezbPr886fbTM3b9adYFWHl/B5xPfb27oDZpk+HwvPn/7qfj6fPePjdPvVmi+4PTvVmg+ldPvFmj+RWlFfYMD30mtv7fywrLfpMRv7vMHV16+dOMH+a/E/9kKAhz3H1QQvpBH+EQ6erznad5//5IVkdYpxuidfP52FQUe/r51/Q+bhtLSz18CFjUOBfHh+9ap8Az4aZOtH/8yUzYhVCMwtMMGNpdomviXDnyUjT4anX6A7QTB9i+/aV7/722dESFGgT26FJWu0i9mRn4ro9Kd/B5EjIUfUBT6i/hxW/5t2eBzTg0eOPz67vyGoZ+Jv8YwOwjHefMuCpcwWWTtd4nhG4D6+PlDiwBfBR+OMd+jPsXR38M+92+AfQPEP3OFPUh89jPJao50UbSf/3o8/jN8lBzTn6HV5te/wjaBo/3VePw/cvkHksvD4T9HLn/ojL9blv+HnJFf0H7I2Cc9Nboo/f+m////POu/4VkM/l/2LOLPTG7/OG77tyD/z0ptCYr5C4Vz5IFiSZbBOOafnWP8tvjzLWX+91HdH5qZ/DO7HPMf9jn8z+VzNMb85cCSv/wcvvbAb2Ho73Y57r3E7g9xOrQU/Jd/kvTj9F//aVdS+n8=7L3ZkqPIsjb6NOtyH2MeLkGMQowCAbpjHiQBYhZP/0dIWVXK6rV77/oz1zE7xzqtuzIyhCAGD/fPP/cI/oXvbqvcR12pt2l2/ReGpOu/cOFfGPhhGfAL1jxeNSiO4a+aoq/Sj7pfFcdqyz4qkY/aqUqz4dOFY9tex6r7XJm0TZMl46e6qO/b5fNleXv9/NQuKrK/VByT6PrXWr9Kx/KjliKJXx8oWVWUPx6NUuzrk1v04+qPrgxllLbLWxUu/gvf9W07vkq3dZdd4fD9GBg2EiP+ZrSbgvN0hia7a+T/1+tm0p985Wcf+qwZ/69v/V/Xzjz0JhOqio+cI77yG+vjK8gcXaePAfsXRl3BQ/gSPJUqYOlHTd6Cp4NhGB8fg0vdp/bHB/81PKeeAxeAlq6/PvxxF6E9gs/UZsyKPhqrtgF//Rf4n5+GqsmGARS5PimrEUjB1Gc/Hgr683ru57aA6r82sPu3rbuC2/9X+THFsH006DT51wa+NST61BCkzaHUlrD473rR9W0Cvvf/vLWt+96mHaIBjvytTau8ytIfwwyFW4ILTUJACUFo8AFGgH9g3b9rC/apFdiYrc+ZHm9XUIGC4jD27SXbtde2BzVN22Rweqvr9beqoYuSqilABfnrL7ftQMV/gbWB8wscvyOoh49agG4Bde2c9fn1uYLKKk2zBtT17dSksEcC8iFIP570Lwx//fxs+f8o/x/rBDwG9Oxt+X+sBzlrb9nYP8Al62cl9UO5PccfViy/VAXBUq+68k1LsB/XRR/aqfh5618LEBQ+1uC/X4/+g40Q+cIJ6+VqY5Q+V7j2swvv6/G3WQNqqIPF6vZUfTzsbgUU3iGKs6vVDtVTLHEhbsexvUEpgx/wUXIpnqP9NsD58+ftHty1KuB3RziVfDR0L5WcVyucI/75SO5HLfKjBpTTaIyA4L3+BDIHZAPbVSfedBZEk4uWAz/G0StFr+A4oSXAn2Gy40Lweyez5ysDCgUnXkX75BBctqV4NAannFAYS7cyYzpJ/8J4TZJQklDtit9xKa9enN1SSZdV1R9htxek+Sx64Kpo3jdTeKnk2/3cdJ543glkuIWhe7o51EWzscdwOY4hGZ1q/+STg7VOCsmcpm1udrnFUnWTHeicDdAR2+NsEMzjnKZUnp4Jw2d9H1dtVdyB5oo7niu+q+ypKe+vu1UvCu7jI/CL+56yyt1Mv7A/Hgoe+XHJV8ug1SIvJgctcrrvbO9HWXQo5NvHohC5kmu1unTco/F98/ezXNwt/5tlA5ZBsxUe8VQw1O23zyOXSE5EfOP9PsqeWnGEXJSlaxvfPo+qUNyF03fLHCiXoq3skIuoRXzx3fPIc4lhR/8R/QGG2r8UpRvq3z6PqlDeBeebZe7nUGOXi3bmv30ewVCPdvi9MgfLrafuVML3LrX7H5hHtb62wjfrDo5TJVB2lN3jdon+A/PIi9n43TJnq8Bu2dquJTwPqb9ZdzznUavR/4CtbXmOX1JptwND/e3zCGyA50/fLXM2EA+1MPlWDT1E+PZ53HGEVqDfrjvAUDsiv4x7fncrwm+fR1sRPLn6Zpn7MdRSp6oeJnz7PIKhjgrkm2Xu11B3+93NDr99Hm3l4MnfK3OwrEk7rjKl615FHt8/jzuVjLjlu/XSjrch7l2GrpMv/4F5dJTe/s77vcr2DuJeU5IMFfkPzOOuZcNvxzS2xtlcojh2AYb6m3UHLC8nqf9+bMrbFcC90U6WDA759nkszF1reN+OaWxNdLjkcLTtrpW/fR6BYkL59ptl7sdQl9FxJxnF8t3zCIZabo3vlrmfQx0d7e4if/s8ivwD5YvvlTlYLm2Ae8vz8Wjo3z+PwAb4hf69MvcP9/Fvy/9wH7/K/3Afv5f/4T4+lf/hPn4v/8N9/Cz/w338tfwP9/EP9/E35X+4j7fyP9zHP9zH35T/4T7+Uv6H+3gv/8N9/F7+h/v4Vf6H+/iH+/i78j/cx+/lf7iPT+V/uI/fy/9wHz/L/x/jPpqH584Ix4HhPnoOf/rhoP/DhfxW/ocLeS//w4X8pfz/Ehey4wHSpsy6PyZyiqf/wqRsEf+F8fhA8xsGCnGZ02s9wSp5xhu5DImCDKYiRO/rWQfVCz31d6rsUBnn2UmwFFCXDemyMRm7W3EhMpxHb+RuZBwfeHOnPD1rnRirL2nqgksNe7sLCXguu3yZ5ygOEB/Gh3vkhs9KRLd3YrzvI2yI2fsSsOpg44ow3/nsKDOXkCplfKQTyyTOVJsM6mIjeInc3P3oxXbbPnuH1aC3D7oATcRz/bHitZIwYCyCsZgb9UyvWO0xl0OAEIGvT+eApiq8qSjkWqa1lfo96VdDh7cme97SPfJlG/bgzR40ZQb/B4dbNJ1f62jHl2UYHv16s1sDtLtdRaxX0/AAyiXe+opQrQoirhs7Jj1aDEXgX2YeOYGPBeKuO4qG4zFisnAiMMr9Mg4+XKGcXa/GQX9Iz/bty0wlpIwdWMJbwWN68hK67tq0Omtv1VwrnoFVol/HO3nfsZYXqFNKT+PRPLcb8WXp91QKonBydG33OZhyuRRplQ4pYdY2AoaBT/T6e9Ya3yZQDu1wvpiiA+fN5i+7ITYqHx1jqz0pD6rq/G/CIPJVAo6+7dyxvf6hTBNbGWm4ZEFLTh6qfhMSIZaLWtfnzA0f6ocGdClzG3r1JKuIcIc9XXTPrvaYGRJpPa1rLsh4/lVta5s6RJuYW2F6+cFuSGFtn65j7G9E7ZiQ9RG41dmBJSuN2NmQm+OaV0m8fr3XxVLAXquq/XjZJr09AYXZ3rmTEi4pADagbkfJtrkuiik5Syof2gyJv2pnIn4BmEmR76HvPGuAp8CVSnRvYrj7Bz9q3cXnbE7XWAMfJw1o4UVSIrH48lzrfAg8eKNus3v6Q7PWCHeS26eBWJ8dJnxOS5szhiJKl5jLl21qYdi26KBTvd3O+ycumPRWPAA9x6/HG2GEpMTIl93hWBSmcd85bnr9Kuro9uCJD0T4XO9p0LPxJcdVL1+T2wXMGS+mSqHZh0+MkR+6Ir+OY9ueD8sXUYh4VmygbFL+wZb2O8PDC7vAQdPIB7JKMmvvfx1ZSZ0AWQEj64brN6K/V3u5A7QY4wVM7Vn8ZjTFt8JzkKJ2QC/H70aChyMHZLKFDZe+ZSzeyq3gQRmKW4a8HZ8o81BrhnDGzrd7sj4UIv02RnOESBkr9pJx8Z7VMtxmyI8APUlySsOdjDMhDG6w+ybLVZUJZD7gCt+d1Y81P6pCQeFlsOTfZLUoqL/7s9knmPfS3+c9FIbG5NkrUCg73MFTKr/Hiqghj/SLUswfdjaQ4uuRdM+vWNV6MbhEsRlUwVxOVYC9JD24j3tw0YX4sgxGHAKthH8/GGcRemY6R+yuLt/nMcY2YlJ6jKsa/B16+v3pvDKPL7KF+m6G9zoV6quOL2sYPdPtGMsKVSxLGwwudnSO+hfl/6hJKwA2MS3uL9Wzv8QDKUyhz6ScZ7k70qlCHd0aD4+WL0rKe2RA3un88WzcOreigeaUTX3OXMjAQ2dl/+AI8KtmDqevRnzl4grH7XS/Y5OjvuzCMTyZykYkxh2LT3M0BUYDnjbdGvirN4kpy3OEnmfLMGlQJVWreqPA7/O02ghruewdjdCUoigMR1pR4NH9HGPM5g5muKI8lEI9ibHoq/pOOC5A3/Xc6orIq1qwqWI/z/P1dqtXMrl1fQRaPOL99Y5qM74sC/QzMoMEv17/IUic3pG8wWvsfNnO1Nmy5tslgPvFMel0R/Mmn4Br5pnyYcrN5jwAp6yyG/KyhkxUpAuRIA8/CPYH+nCdsFivwD3FR2q6UpokXhDNynWbcQUeB+AowL1Is3S8XvPzTAc5qHs8Hmy3hu2poIn1nNw9Vm974FfxR+jo4q9mgP8igeaYJJ7XZPRHd8ZT30pg8++vj90OPsDEhZVgfMVLWSsnSdBJydqIOB18S4nm3Fpjnop2dnY6QfeKXwa1LggM8bSv+oI+5Hfj0LvfHh+rTS53xTHLMk31g35D0evs6gzDXECTKYIgyEhXXLMRmo41G3c8X0esP3ZoDZoFjwSIoI53HYe1dqU99vQJjKzWoD2i7cXzTp8L09rJ4BJ5dmu/P01B3ze329ie26QiUDofu26cHg0WHKTbkX6g6VQ7knnwKOmxhhfVbJl0c92NYRnF6kCTLKUe89khiWGey9ojmQTI9+Lg7UaLQcmSe0lB2tCAj+yNIZp6bUmguSCosaX02lqtEo3p0WYQ2pp7IPfgQrguSgXr9TBnqOtucuQjHkv1Sgmrk5hMlluPYbGa7EjJzJlyMm4hORVB4VqUW+/wVZs97o/AzywP1xQimZcFSpaSYVkUNz37zCUYbxIBxs7R7fgAfWENNXEbHMcw91G+pIrGZ9pfiP48YsvZ0OdOugjirqal+ryubEFZa+M/VxG8OI7zBMcE6PigU7d3qpRhCRzlFcnZ0vlDkltjWlWsZaRtsoT6cSfkCYXcTQbXwnxsihP6SIQoC+jr4wzcC8mOFYehIj9g+rGOOdPq6eedeHME/9DM0PY8S0RMk5wUCaPTCStAPeqFSdZ799DAohm/3+F8PFKqJVeNGs/bJOvE2jjTfh2btP8yFwLK/XFZUlMrr16gNz/QL1/iRQyUUT0oomwGj7tsYibyKDrEsp7uy1wi/LqUnlCekH5P5qNcOnSp07fk7PBeWkusQRhNfKWVSXcCAcEOElgh/GlaE/Ar51r23g30RJuQLduHcpyrizbrCaFODsFmvTv3aY+OaZrdUMxUhOsxIqYDYj/MdZ5vG1SC+crucZ5N4WEqPMCn+IFG72gMtaaSwZVxth0aLyIBs9qU6mr7Cxb7R1ks94lh33+OI9+ZhHbv+6Hl1wNKswTZxop12/SGFVagYA3oZx7gvDPJRG7LguCkQJ4S4ZjdXJROezpA09Mpw4mhuOuxcdtUKG4NS+Ms7BZCJgpQjPohmYNDsYxMbra8eMyhzLEsNrPphEdXoCUPKJlE69ytfqE4JEvT/YW5NvM0RXkO0PUg9xPFzi4w+EmWVTVexaygJLrkIHmlpzY9OuI34NHP2UWIyu0QKrU2ShEw+bTt9VysHGODbQeqE84hmhGOrRIPMtTlHa4lWt3QJHlOu40kWCgvlCiHTG6R9BxgJZDT5tj5DUqNAVnvUe5A4wgyP4zHWQfquDyNJuvQqXOmM4HcBMsiDwSON2Xdp9PhQFMhhlGvxZyfIwwpZqceIBaZKJqG53ysB+llWWchEsGCxNl13KSc0v2CRAU+SZSwUTwosp7TfoNP9Smbgue4pdcb4bKVK1+TzN1RDsrhHDq7chteqsoSBFzOAZTzSR+aQSDbLlD3eQpXAx2N/gzH1h6MejgT+8sjvEAFRfQDoV6Awk+1Ovd2higHmKW4kw8sL3WNX0pJmudkZu+svkGzDHXTlGEWFLO1nrFUYW4mUlA6DR9F6yHdjODn3qDgT4BVgJEDmtUhSWGTLb+XbuQhOB+UoNCBqkirJOdgc6/Gd3h00M62S65ru580jSLwXKffXK9bxz7DIQLa0f7kPRneOaXxQ8FY4GdlH49edCPbZhq8v98DyKGy03aeYEfLhcgUGk6/sW0fYmKSAACgOOkSwKPSgcySukel5hagGM1S6aG2ian5iXQasq5cvpXCkqU0GeCW7I67Ux+aDTejEx4PKDQra8zegIU4NTxKMXBWDwfseMK8DKVr5Qxk3JLr4DLcgOnZoIXfZxd3STMhvLPsy4DoOtQNBdEEJDOaQnesEXFGv8UjLy8wh2NHfLAH6mO1eT8TwSyaucvCBvC5TXwY2dnMS3xrqSjLtzkF5qK55dA8vFCENb+uelxpRsdcJiIZLL6CiomBa+7eTzBkARllZJ6B7aavFi8pzba/uT2RUkR63dWsiKf10bgVjF4PERsDI0kGQ6o4GJiWkE69iNCnc9y4eZbJ1jTh9UqMGw7VC3hGkA922dKXhWC2biEvFDxGTMoFQXha+ho/LywY/QPD1mYgFLZFmwMDG3fDGaBvq8WYqfDJs6MUNtY0RNNudovuGytfDQ2KohfCGdryr/qgsLxoDOQMfe5DrmVZhxHfjQfKkJGFbHH3u0sz46AdeDFWZMLekci92ZMMQGDzmLEWAXLLQ3lkAAyEczVklnYrIhMn/RVoMCQuA97J+ukasC+VQrAQBhUr/GcJFBVbV3wA2LOyAzQfUeFKMpPtLclLLzqLI4Ub7cA5Bn7UcMfjvu+6eaHtuydA2J9i6XSmT+i6OpDnDEhzdnYtLRtsNvokxMbIlqhWADwTY/V5r6GFSth1gqygKJTu2We3Q+ZGACJJUmLlMH5Eke038FcCcfL4BRV/ZDfu64PESaI1yHQSExfEh2txVhQFCgC/LBd3f3fjuB/GcmPrPaveR74pHzGt4EEOXSslXezhqUccd/YPJJP5lCWaoaLgqw0BiDMsqVw7wMJIPtfNaw9QVFSsAu4Mk6n0D1IHNrrqOThPC1Q5vJunLHB+0N18BNAnRSFQV3BzrxLaCY3TGoVtA/cniI02oxAtDCklGPwZpagt0/cxek/SQRB0c5WSpYhYAQQhpMsrjzqOwy7w2xAuzduWQ394uAUwuDViEznKOHNaoFTUdJ/1PHdC06BqWJxo157/KsPKVUg5CHYU1v7tML5YFm7HVw/VbuOW4L3cqt0RG9kHkfjQwIpyQjh18UjHJKGaGohTorAh2xz6eRhKF8wDqUjJXSdMi2uXll57Q1AciaoVUU+PK/Ca3ODDaAK9D37d04aY8H78kPvQmwhLZmMS/k3EHwY2juOnKR0io0kTh7ijCEOwzb0WgGU7+tlrFURxjGvQsb3nLnVFm9d3Z1mGR7V5+HVioRIk5cQ6miY0kCqe4uno5UEP7eUT153VlCrq5U6fzIdKwMUpnykLLix44li+JIfTYbZLqELP4P86geKxu4xf1jOGMHAXs1HPHXsaztaHVY2OGqdt9s1srqyTNR2V9MLjpwvPw6XcDwPDHmPgdeFOKhXKoUfvCNnwDb86mqUimdkpQAsfs6cJoImQuLqGlhwk4LzNHs/V6xfzUTV+z9uOcqOdpbxgzx5tRx2yxszoApex8vY7lfJPIrqpX8y7XvRCglGH+O41H88nDovCI7lRMSsYsTJpVaGkipDLvuqD2XsdRkuwKBP12zM3+hg5D2Hn4xKWBh66UTvjCtZKB5leGafE7uvacLuMHJBOBhNW8RNTfhBBv5N42kjZ+WL8uojFowjsl4Y28/59PoSdCdlPgqhn5KsMIF8qkHVIEhePPrVjX+9TIcXPOESj7kZRU98rVG0lX45c2Kq+h/Pli6JefnsuO7/MhQZDsHzSk9vli1r3//+tdzURrbbzBdMG2twGGqBHR/yeXsHon12ZxtUmBfXl1R5Lp42w8ZDEET6SCWEFevVd0TX1le9wL+/ih164QDR4RUd/+Nq9f3rnhgajU2150o/hS6WKHdxL0iZdqxdR6FAmPblXYPjzmx4tyZfHcVQjyOT1qHEZnkyezCVQh6+rknfLDRlEvjljGXQ5Z2z/H3ti1BenZoQu/laZyz3Zi3KBviIHE/T/P2IH/9eja/IB9Jq0N6+pMIVW5gzassWdvbRqXdD7tvtqTPYg7RaYecZW4sN5IijGCW2Fx+fdDMnDIg09GBXf4ntsfHlv2tv+NkGQOej9HBvT8Ge/tuRg3hOwRRCrrVzywim8pH01jnQ4HmHGqIYGccC9ZbNJnQzj5mevG9uvrm6zaGDcq2Z069NnN5iRTfmel21f1VTL4QKsOpXdSUh7frpi3wHzN/UagkXG7paLxz36Dbqqf0X+o3Q7q3808sdS4lLwp8rxe0eUvP+NruJg8NcJ26H6/kyA/ihCTBel7vlnJCFUFxGiUq9bzO/R7VwNx8u4qKdaFX/2S1PlF+bN0PkOFH2c8uTj6Acd5x2ku81C/zX21noE2HdB0qz3sBsTfEvPVWWFSFnxWBZ/nH+0NvKm3QzJKbQXWty8bcncnyjW7Jj+dF9VHcvNzUQc1cKn6eWGPDDNMJKDXT6w0v6GlhV724M5RQ96d37xd2XqtsadHHoMOuz+HtK4fTLZwIOob3Q0yo3to9Hod4HQEswHMwZjiyekW1j9cdYJ63ZvvyM/hW+tAkhiFvXA6rsvq/mBEBLpeBx0ySFfDh8/XRLlgNd1PT9WrS8oNTRkVhv82HoAfdYDfHg3oC5rGnx5BEFzvfrNruSuHdY7nWZrrYIDnyZCLRzSPk06beEloijqfmdg326dR6UP7MUVsZCfg92uFVw4k8zkIK9GYHi3frRnJ44BTTBrRjVIEPd9nN2Afs2wecLidPKvFRpDR7IRijUNHKTbofE4utPKBOVC5C4x+AeGLKvAUBr0lOZbgFfmcDChw7g9jjTbEN3s5PNGMAE95uX34Af1CPBDWaI6177wg1QC/OCSVQ3cdMXli8IX0vIxRvXabN2CxCzF5JBsi+YXGQh7vm3k89TtOD21kFfqe5qAfCFBLecLa0HijtxoGsfRsa8wu7KcutqyJmeCuFxCm9qtjcfIOBMcTnePo07Qu09sF37PwZ8ENYQTaygHMYzE9etajm4pZvXGUKmJm3mmZPqevfswwgpWUegFM3BiWeayU0O4/lPgwioSPM8cthg2sbbbdPET3P4WDVcfLzD/Lkxd8f6qLlUAbPEkCac6pG+iVu1aSMuTmw/+PWL9qWfwhmXTvGk8rb72yoFk5RqyBcYl7HI7MiYkOhljMud5jrMTJCpMw8D38bzLdk2Vmo1Wo5WDKgR9eWmxy1i1hDzNl8fjNWKbjLIFZeS5JUnxPCMIDMtv0LArA96jdILOTP3k9ALFgEvlihkWHs59SNxnqw9DyHNduxbGl2Cid8KCHyCaXrvgeGmWSIo9JyfvMeWGmDOEYyzsZTOOJYqmCsH1Sk9Hzv4CKg9nLG0gouB0zC6+qie4UIIW6ySVLK++PlJ4rLDraj1eiKyJaqcf3WfiQo9dT+D5cJEv5DPakfhjsa1XRHKelDdxPgXBcEHhEt/WFTc2etdGsKkb5IITgqYpWw0uVGxUMAuCz4HSAVKn1UX0xEn3u6rVkMjLrZ7snoxKfqOBUr9BgVf1U0gXXpTDOEPnvBh/SVBm3bo/LQHSO0EBeaQaeLWaRU5+36CqrkBxPdMbNs3zOfzQeVUN9E+9tk96N81Dzw2yJNvCIaAtGOeQQ64U/T6NJoCZa3So2q96Gv9d3ivOqwkdWkdin54Syz2fSdaAiR9AHDpUENxp16LmzTWvOMUgOM48+Pl4H0bcarbG7bpz7tvNY91Xmig3jMJfMnqix56m7ruWPZnt6BuZTDvtJTjB0M2CY3eg401FKFnjOWpznMOo+yE4abW7ekmd8fdalomWvZ9SffIPWRM/Q2HG9mDPw+1IOOza/+6T/d+vfKFQC+AFXEkx3jNPnxFi9erAZw2Qhu0EOTOtrqgoNW9Ta5qEWXv3k0WzzhAqKpaamG5Y1rYsWIPTI96R+s2lQlFRhnLkwMWvNZzPPY31u5bMoAbA0DGOiEpJDGl9sFQUxQmMJ500NLuxLElD6pdhJnBDdoRcfNY3p/1L6U23Jp8gDhp8A25GmV3Xxa3YYIGWJNnz1DhdNj1WqHr3KK3Fd+B1wRy6231vOOIXdzeFB/vJvvmsS3+4Hxz3nAmxcHN+M1SzFhNNNwJBkk45csMbpp6jdafSMn3X1htWWi7UXuMNNj3oIQjJ74RWD+HY5crTSN8x1Lo9B8A7Tz49YnGEjvT4IJ9AxclVGmcbtOnP5uGZjyfj6lAW3lcxy3oFzpLi6lWA/fBr0qc3wARnyBJTpWPorzXvCCr9zHHRTuaNtIQrxATI7bm+KbPuHrZg7m4I81rrvm2GYE5pGiNj18EA1gHYZN4mTHsmcMGYEAmXRtf383Rv8HkCP0ckMxXxSkNMQhvxWW9chFj7AomNyVnXrgQquzvd2QAYm7HfWPLxeGBoWmbG4QGQwFyg+kX9IvKv9DIUCkqq2qz64f8LNspplmXtVeECcS2jq4c0S9aIjQZDCe6oQrN40/d9vjK8lVeVleqNUK3SsEt035CF2ywLHeGg1sbAgJ7kwij5iZ6moImrVW2S8QYA9L3tIO5AG6liM0urRmw8Ic+xSLWYgOo+03QasYnU6hEqb3APYdjrmWVhVk9y36FUmdHVTG8fIeABfPJMzaBItiNedhaB7U+VEGas1WcsDx/mV/FD+PAKU+j8YSQPbz5tncBWxs5HhCNG2DTNUfT+jPWzEx5fsX2A9CJhzc3lsjrlthnNNrnBiudVujjHO2U23SOFJsdSFCrdlTZ6cmeaXS1CayAOinUqzScHhqAPEGXnLaZfvsoc3vQS5itL9z47/twDyLdJK6o3kqDSY3ciEtOhD3RESvoUzzVYJxiZXxVhUsIvIzJbHqDXjzkdblx+7eNd3EIDQnM+R7fna3j4HGhcS4ml3EvWu7d9ixfKp5CFmi429eszJ4JIVmiRyOjwuEOpFOCN1YNBp8dt+zq3aRxkGHN3Jbgu7O/KQ/9V3rOQ4YPm507sv7j749+UkQFoi4jXg43l9rCmFS6iWbeU161Aq69dXQAT+03nMtnGKzYTZxe94T5iM9Fu7I8wOENneLUKxkBuolJvm5tueTRQJ4j5XtlNGfQuLjaRWUphxtuE9/2I0VmPP23TdqYS4DVGMEdn5yvCypoKjiOeC+Z8PKoNKzvfMXaR/NoD1cP0Rkc8QrkDPbn6tqfskEJWTT0MggBnWB5Jm21fOUDhZ8/lfj2S2Q0iEjI/Hw795UKn1RoSRXOglw69xsP5OqSJiwJEst3PLPR+WXbd3JWuZ4AqqDk+Fd+RdScXJlwn5L1yvSdXy3PiS5+ujBcu7K4lpiYesYvMwhhAsvEbGMHuHmd4ygrsDkNCmV9Y+HKzZF93VO8rFgr9D3pye1EmiG+KcLdAMuXZw47LUzJDfgE4JlfEnKly5U4AjM3kMw0NtnthpltwuI5dO8/AFNPO0yFQkSw/XCRhOh6uD0I38WiMRjis689QrJhFZmBA5DLtBIHVYDbuseQurtc6loIDhJhuzU+fzkrrOPFZyphdhDQn6ITT1Ci7+8vY0ekT4TU+9UIQWcw0WYE/rdbAWvQzJy6nYUYCDEGT2CxbwEu5kIFU0ojoOdz1tJh+m/zH9mAUlVlX0FFx8hKZM6FNGGB/Adod4rFRRIhdLu4eofX4nKbp9QpJp5PQ8PJgmcD5pA95Gg3+2ZpX9n42/NGfJlmWsd6aLfcInb0GZkd0bNafYC4S72kn6wFk3+1wkwNLv02chc3qI3QLCK0bWRTz0TuVyhfzloQ0wdJPXESkunqjaYYZZtLMmc5B4OkBa3QrLl+Ufdd0PCC40gNhdx97TqakhJFn5VYarFixhFmBiaJijqTWBSuSwTbaXr00Df54GMMcyq4bhVPumJDzMoAlIXGzEW4uRp9nnKSfHs8P2ZKg4ztOQXVrAb5k6xFT7hjwr3yCuOKnwcfBqC/Qu7qnq7MvULyXZrDK7xXNSDBSJl0d3XvuVP7K3hiOhMxsU5h+/1FHKIuodgUAyM8YDmjBhaG7iyxRxe52A/i+RB+dMOL0sKH18+2FBFlQqd9/OEVwgNLn3oh0iM02VvqIX6EXKggQEetwfyTE3Hvoj2YbLKJC2JrSuqUDllo0THebZ3OCHKu0n7pe7COgWJLDcXaaJiju1hVNj/rXfWcVrAHZ0cyr8DEmk73b7fJGkit9dJrLDsx3DWVuKxRrIweUZFnuflQe2EwrQsR7kF/I7nAXhnTnV9c8SFATTKONBIqKmkyGqJ0U51UpdClcMgQBc2tz1S2j9rE97GaHPA5aJH3V7pgtsJu0v081/udnkHEJeh5JhnneFsVYmMTc87k4KYZp5RPuzhf5DECDWZU2kQMgXSYwTz/hHCdkcxcydLqIxUaFWcBnAqhohygYwKaLo1SPqdt9NYNZ5kWYl6Bih2P102pxy6gL5+KuO5j0ABAaMaM+wkc2yZraaNwbHc/BiPcZDjObu7VdzlH+1PDxdTSAZy9v5+HIA7/qskHUitL4tm0wAwj8mczJNYA7XVqBhk4ph51QAgkmu5JQ+FJCTrXPwFhXR1WtD7xTNk7xsO52oFcrZlUwN6woleHk8JB5M419V6otkSnO6crsgvth3ef7+0G+UhyRnLcVd2XTGbDD1LAMyjzu94yaqKmfsKuPshTZ2KfrCcr3OcsotDml93uQTxvKpB0Oc/2aLtrXddTemgvHKI5TUL1bfnHEtV0DY33uQ+1/6YEjtYMGsqA04HkSPcAdw4AfKQb4gFUzJHrrJWl6ciGllWX5+OgI6n5rkBlYTDIJoIXrTz3R0XR/a8aMTqE/w6fodWwTwbkdWnShdN2yyPUmtSi7zlSVW67jYJB13JMsk3TdmkJ59HxEY1tvIFh5ykzTZA9b7Qx4N14tBIFxzyjcOOhXVUsxrg8k0758gim3KB/5E/q6Nd3+xyeCWBeYn+Vuphx28hHDHwcRuLIbWV62fApv0GPrgxnYgPi2BNCeh8cas87dM/pxK+E7mHm0wAw3OPlNfDqdMpSqrwQj8yuuKMoYavVwOtAUfR31dGx7fr7OeZ5pZDj6ZHyPDOgURkuSfdnngeXec0X+MeJnvtN/5qVxYpHjwNfMnztMWIZJ121bA5qd0DjpPlLvpnsvu2a03Yn2XNIpZKeY7Oi6eKgPXqSyu48MaW/oXaGjM4bZf73FOz4rLmo9nvv5cPz+M/kM/gQj2xpY+t/jgb2XD6hYckmfpkR0fJ2BiEp8QbMU68T0abBukftNz/N2MFtZjATgMT8RnMix/DG87Vuwik8UO3VEisjt94ydqkQQJaC3qDX29rMNVNUWptQHl9SYvuxxwLIosDAHgtrSm395PJ9vOLqtaPi5kC/Tt8yRJsjQ5hgOwbjH9EPWCihrdXM4HoH/KXb3DEZPpQ3fr0T/5dETuOyZfbcpZna5PevV1VWXCD/d0cwa+BPmhgI3cUDiWzquzGH+ak9B4yuxMM17+qOGByrTFLoiNDy4s9qB8q8Sk/TV3kWQlpTrukQL5ZX9I3iwt+1KQO3xSJbmh34l6ab78n6U5aT8HB1dhueziTxHTRmnys8cR+V0GfZf5SBM/ZmTaTE2dXzFYWtprOgUYxyD8OAaF56ZA1GE5V/FQBpHPc+zcy6Hw/ETkrdvT+/25mnV/ovrajfCdRVtpwQuo1995YsQ7iBbxm0ov3ru2jHaVxyB9baDOsyncXVOyoDHE44dSDajeyl8juHXJPzH+QTr9TJ/N6NWuCI8ljktEMV3vs0u/MC8HAPnYsXMhP3DvKH/TdSVgznTrhieJeTy7EvWqrSBQbrIQJyvRg5+2gL5mad5I9U0LJ7PvlfVAnkrYH2qtquSRlsrhj7uv8l2F8/zJBfkfDSQ1xz57aGXKNYsToq6LZF60qTvwgnLjoNnIytt1Jk/qo6YMOM9NhJavVC7JXGXi62JCAy7j2fRUwCOArgejS7Vly2hVgUQo8/J+RC9Tpcr1kJdEawSndkYo9v9Xu13gd1CSVVFDAJw2RVJUxotF8FO3v0b2KH7/gb1usFf1u25vjQh4yac74vKdvqqB/4MJx4pI437NGtPVobheQRDXiV+w/MsuLjYuOuFZVwpK5howfMXrBSU3phYGo8QbbA8uA1qRl5Xn55X00iSzV5JDRCO0gG830WIRZx0zzmaVzEy6+a9/fr8QsivUiZJ1/0rZ7PSvR1/44B3Ee1JcpRy7sDtiyxFTZZGb1K4BSaGBVfkFGddgLFTPGITAyPAwjmnHGeIntv01KOMs7NHWxl9Kru6HFMDy043xoK+wD5J+j7t5zl9bEIUE8c8e2A0vTLzhNfjiazNcMM9jOzhPqwxC9kGkiTUTR+yDbLXJ1f2lZjGWKGf0623sj36DZKGvyQttagPSesK1QnUeXlSlEWoHGd/K4YSNkUWc7eX3DBJDW5WFBi3nlgcjy5w95AdM4+ljdi1PwTPi/VBlufB2WT4VxPLZnwFEn1MLPsZrytvM/TDd90pJSaYyyHptR//T7Mn7/fcv34cxhEypxEinUPoQR9EimHqzsMkD5mq2piI/51V0HgL8ttWIY1u+wMhIbbF8LKqbXF/ytV+xhXcI1EWmGdnZqkxUgKBcZ31QtPbMPhIGzFxn9N2nJVXzETuaBJndYOZZOyUV/KKyEh4pZWB3TD2YuWuPYSPWZnYGO9PBO79zyjEoke1ohEw7c4knkJDgQNWzSNjJ2M4d0K+hsXf5oIu8mtPDQX57PmPV4sY3kQ+6zZVgeHHI+ndBvNqngoKp9uaIgDy+rv3kojcUeQ31PnjzARuKQuVNPh2qyoKGblULb0kuN9C9u++J0gwsHck8Pks/3H2g2nvjsvNIC8RXiIw6zXfwjn5W5vr8RAhyJqgOrc/Xnd85dv68pi66uzcMlxjtHrI8QtpusN1W5q/xSaOeIC6K+OEofnTM5B1buF9zUdUYKSP+5uSYY2jVHRpqwh4SsGJV9E+2QhVK30E95ZqY/U35y6JAgmRK3me7pc/jTmJ/JXP72kcH9kQC9M4zZfb331v9ODbG8a1Llv+TyONqlzu9jZ5xGrdL3t0zR717m9Phy89G8YT+L5uee+PURhfkXY6chsHzydVUUr62+/t2mcMSwhk+zj8sQYXpLXImpbiXfyAS/JZJysfO5LoM+b430m79IqRpZmL3v5wP1JoLiLTPupzcGYAxHtU4u5u/+25DIcTPO2nT0Ii4rw/tFI7vrDnC0AhyRUNdFO6+53S/t137Po5ljczqv44JmJnofzMH0l8MbcHbSsinUDl/CTPn3qocSawfucx2qwFD9p+C990cKV38NgCpTInlPjDM+Yr/Z4IdzSaDtP+5HT36FYNzf7hanevSH4blRIgTtftLnin43zCai4rdeyV2MxRNa8cJZ20d31qw4xtsNCx7c9nILRn8ZhL1024R2cY2TB8Lzl9uvrqeZXOt0LbywRdSzAMrxAHfspDdhA6W7ucnJWPj28+rFTd4W4dbzoNf7znUdvduBuQhrJDV7EhPq1k4IbsJte6r6JJn7zK4ft+XR6XHQrp2hOOft45KvB3zksnZ0N2LDxq6g9xJF88uP0aNg/R6lLKazZ9vbpyr50Euo/T9xVYq1dOaBWZ1tTDwRUPUQAPFpNKMr3va/S9h3fI0kj9FUlF7M8RnX705Y4w1FQO8emTN2Qbnngt1WgL0FYgmGnfHKMRT633e7S7FuaRnmS7Gf8YVQv8pXCWLEiq4dgoVa4tgU4gWm2TZGu+518B4bUP682Fe65hzCyEqTctdOAci6A+5xbdtQnuLEsdhKL/2Mff28VFTk9VaQ3qCtdyLx8Srbxt5/HYslsoOGbzPH7qs37en6Rd0TPSVeYOvmaNnfi+kqwfXNaGNp32hxoGGMuREzpBfuQhqfE9zrjLruO0OshuzCdswckQ3x62iMYeNZ0irUa4UXDPlU/jroZXmMV1kBv6j3dfS5xbxHtrurP7cfPdd097LQ4uwuqdKXU+JPYPlRaIpRS+czY8p8A9KuDTgYjGTv9jxKVyQNZ0YZU89NddBc7mjrfuftaEoJtwmmLRnXC3hfvofcpH36eQQZkYPvxjJl3mX7sh8eDCnodfI/48MxRms/bR4whjoNipKD/NiZBAvEfV5xr742zbF/naFirw4t70nXfouFI8Bz1Kpn6Pg1E3uvUKT3zdqyfv+L66ROitqP4hlv4Y/YjUi7eXvXH/tgNI49PQMG9uElxI2aXbnsig5ubbgAzCT1hM36lwvJ3Fmcc/jre5mqQWLplM1OqvhrjqrpM+gpOfe+3nU67Dqy5gdDq7Z7Z6UBVL86P5wO5e9vl+gyPya1rt9+j/es5/lCUnUeVzoM26D30XipiosDq7n84f1rmCN7Xt3NPE0WlQB0VWiip3n1axSkA9iSrCafzjbDv4fphgO+bJFnWf2rlrK4QTrczc7XPMv8kIAtaH9gn5x60GGQcnRknFjf98h4YA41KKsxui4v2dBeH9AjcyKM7Mb/ejNzU3DUOAtsFC4h31PM9iPTmXPf032PK/w1ZiKDFLj5ydx/BQ993bG4N2S6jKYeoedw05YPGpX4ymkRS+S+egQ4PpmH2yYzsEerS96S+R+6dvr5D5BfahoI2T/pZ7tpicBGxjJjkt3GtzqEPu/VMLcoGZcsZQ64/WHSzLLcwciipMOR65t5kMLmLSlEgC4B0+XhV14yKKsYfOD6PGzr3j+yrrvSP0fNCA7w5/ztPycPeyMNKh8um9d+Es7tnewPDoKmqlaD+MQq5EG+5h8hdKJQGc4732s8b3Flt0yOFxmbs/fuNTu7Oh9jwCBSi8y8UuVIicH7aLHo428JekFuWidKuM36wN8no21s6d8MfPFhkYJYIAz0fN96f7oahWzHlol/btmzt+XzwzHcnU8o0/5hb55zuVgkIO3fd7+q8YY3UTgCi8WVv3ydcTfOxRf8yJR5ATR5DYy5E3WyWoUMr9RGXfR/CyeHAEmWnu/pwf4Z7nIhzsg75/v+cBnn2PDavL/8qbgDsJoA8UwU2qnFH96cnUgm3AfSdRZNwt4q3H8JQ0xTmY8rs+KB6vEciCAPn5FoyoGmyLcRUueUXBzREDmCLSqKzp1Cvp/q4XBv4I0ZZIOPhmwY2lyIlBiRrm6UloreXBufwLz3KMNLgbY3ysqKu/oWxEhBtgpByv74rWsQf0+YoY0nQuYgx3tlFdxcb5lrWfdofu2gv0W/cAeqPDT4kZnEyFSd1C3ZoHcRVtyuQvimaZe9zEDyF54agURqkDm/skGf4BjJYY1BkCT1bqSyWHu472tJNuCktQjGWc/zIGcDYhSshK5n3MPee4e76c4pLc68PE4NfwnB5PFFEd0sfndxneKxt4LnfguXD+T7nRFokplL0pX2ihOWPaLAq5s15qJajIuzu5l7v/makAaiLTaMem9G3OWTUmexKYgtxIl/bcDtSe+E2zH2o4D2ZP1jDz8BdjV0ThYZWZigkm9bP3Luzh6nAdMs3hOySfT3buKskLLYvsWlqnkYY8svjS5DBbP9sa/fPa08+KCjwsmAA2y/ZlhLKCQoZS+zcMpQFZycORDKmx+aWVpRWeEa7kKV5YbyyBfn4e92/p5/Phx1uFVFHbPRbMMPrs6i71xeLbieg/+wFAhPfuMUJDRxkIh/69DfUhA0NUviQ6+8XZHqMDPJtkch3UTd7x4qAKVbRPl18tE8NCFxA28AB84+1D498QbzW27HDMzbo/RetvWbezd6rAPwc5uiHphd0yxcmnexhEefNby6EnMsOVnbVYgJZvp4Ls1gSOBnGK/fd4/h1iIOgVsitCH354hYUdqs8I2NVwPRxH9zA4YeM+w+ZusFt/G5GTGBU1aJ8j549e8ybvap7b1LN/HzlBOkFOucWVd06ZWxyooY5Sha1vfIouvFAycp7XH/Zb3xEws+7oTpmhsDh5QHmvPO1ShH2MXfbp3I4DCl2QEh9HCSvcQx/BE1qC8C9ne+zaGeoKK1CO3PCWaXX0YfR9qkGn17eMBMM5wEyPYDKvo/3RKqBkeVmTkcP+tguRnK50Qi4S9Heecr2ORXjTqkqdD1dJASNlXFHN++tpEH+NV75miw8nIElUshv7d48Jfa0JGMCh8utP3akjkjqV51PDIZqs3lrh0N+320yWn84wC4+EmLDqw82YoXVLdbJRMf1dpu5axtGECxB69gmhUzsEngAfP/j288iD1djnTEgVP/f62B4hPs6LqBVFZThN6X32sPki505VNypEf5X85Lxf/PPh/rsXbluImO+yASfLTn/z2xb9+e7dgLzZ4ztGyIrJS5ezMzsY8ovb55aTKsGsaoObTKH31NjePs1B53myftwLt1POatEx4TvpL7a10UV+lmi0e6T+bywWz2mQ6xfQK29gy/GzVG5wR8A2zPWvPAiBIzj7tn9kB5MD89U2RWQs3mepcOUTJsA8wm4dRWKKM52o3FIv3dPv75wrgqdXI5Lhhitu9ObVFCn0s0y2jtZ3vGnnz1Ph7lbr/MIHhnMKob8gxtnjOF559HCIOue3KLgaNp513s+p5SKUJ3th0oQe8xc/AdFYDksZmO2KYRZtv/tKlQcPSve76JK942mtHSDOJ1PFMX70QBMc7uZlhf7YdoPPfmbJYUrYLDq+/2Q77LCk2pr6xdgJvFa4lS+XeVkxzBV6zZQTWSF6Y6RBvL08VuBZM0UgEh39+Q06O5fXWyamxv1h7nbvyOr5njk/Mjr2feQO6gm7mRypsNd+30CsgOFWHDa/+U4I10i7eLHhfuYCtUmTGdySy/BTHf3wpmQl43N+30D0UZTxaglUTEpwN+ZDPDFs28DI8IwzcPsVfDEL/2JdVaDx8keqW7R8ncV0oNGp/vRWvUr3Ky5P1hKFZzs+8t9yNWCulnKPxl85iCJnFcFaedBIn8gGP8eatTFN9Wl312IU+1W/sclpP+dwR0bbMS4mpnv7l0RoNfAQDmRyai4nCpjmerjAzQ6YJC/R6tVg3YPhdgJVB8KLsw0zaVcWPb5lP6nKKjK1Ig7RTOLvPBm3uMUBdqe8NUh6Dn56K4oKTUboTqYmddrRx2rSbIgi/TzLT979UpwnBUjPxMSCgmf5RHgq+mvG+QqzOco4TNF2VKZFcz5yGQ88AqRrb/WkS0IKFq7+PGvzirVHy/axT1lJPDfZL87zRN1b5J2Hj155a0vQ/+T2Xfm4g2A6hoc5FJR8eJ7wf1Wyu+LowX2jdp89LceuLyKO13ktXkskroO3uI0gjYUWKQ6uh8S57OMbfkEqumieGqjwqoPRd/YF2hHtQWdIGb7rrZPkAJ2e0WHEv2ETkX9FLE+3+6/9BrvlrCoYI1fzhe5qPFh2kyG0l+E6Hj/nbQ28tcfLpbTI/bWaGo/hH3jFy3KJ+KR2/WmnNEHSy7I7UTlMcDn3NtzJpYdoBPconI8MPMe8SIgd4ybIXBrYa8sM0jXoklOPyyd9RFUP22Jnf4I3idxPSHb3tLqHhzy/YTp5z3NwW3Sf5SiPNMYQ7LH54veMD2M3OvSC3flukp883icGpnaZuzgWMV1itnd2XD1fc/EQkzVp5PTO75Gond/eoQQsg+fY99MYRjE9nsEDVtQOkknWsXQkYUuBbgoqmLTPM3m2+Uzz9EPSuIOJUp+sOvB/IAXFx5Gjxanqf2rZzYPxbv8QQRl803I5V1+5GzwJNgErBGeWJhm8tRhFtrh9Pvc09Ahgp0swT3mZITnBqghN1Jm3azGLWkf+hw22xRDGrbTcS3rCBb9hNHDfNGqHuJv+vCIDV4BK/1LmRF7H2GOQWKb+LYKAvmRvViM3ecOHfAXxoUQZ1+DNDuw4t+pe3kTLcHhFfMqeN6Si4IFXWkJwKL1hZqk4F1dD3avns0dw7tHsjy9Mg0hDkSNpQumnAwPTYoZJSj7hNVSCb148mi0VG+/2sVq9liYzfM1oeLTKJ+kYjpB1Fffm6bO15MNShAk52qlcUl5qT7/yBWSt4xunYvs9QnAHbzxaNuwPUmyPHYUdeNi2eu4zLHDxh0L9igPybfeUBsuYdXy6nD6jBUbdqSS1f+OOtYqAMbOVS1Xmk+TUngeZMyLigeJ93SI6lBwv5vXpGq9ZlAzH3IO7AZAiL9Ko2T+SUUlrXWi9Pk1ZnRx+yxl6chCXi3R6j1p64gWeGa4J7Sj/8maf7106Pt+7VDGH+rc3DWpiyl00NXFo72c9aTdiku6I6wmgAio7VDbcH1OS+fUOkQw8y3CXH+BhVhJETCpFXPf7Rns8TyCHS8tLx0r+nP+jQtsYaNiZfde6ZtETXfT4JAGLVMCzfE00EPpPPCPMnuAqU2iDs/sD86iStNs77HILTfuBVbtTeIWy7HPNhSaClaW1/NJaVBIxn3p9V0cOS5ISQJOZPjpv8ZHi6QMyTjfPx9NPXaY+ddmoZxaBBem+ShqEDLAH1c5Ue+nt3fAZuwEo0+rbzAti7OrLXXyk8lW6zrN3RK/FT1/laCvLrfLN8nwOiFnW5fmgIihwa2DmP3N5lOcERxqoKCk41KOLsATEPBfzZz8ijYQNK7YY5+m7bH8arQpmPR3e/J3r7mG7zM680Ls+ayd34O3PuNRsgRGE2wf7rMNr4wEBU1veNfvnakeLfCU7aq3hWf4K2WkP6n6LrnBFiWYZqJxWn8MYJTAWDFSAYUgef8rN2peWqjwPGrcvO4n7pK0MqK3irFep8oe2EmxZFQnPbJ5yhpzdEvkcC+NuvMmL1VI6t4ICUisDxPXxGQNHjgdwMnI6rgvYxXpJU61LYXW+Zgc6NC7Nw0wOnFUaEUWRe/ttzY7e8QGZwz2xliJurb53x1fEedvDoioPqEcftOGJP3l6vgBjsGsXozwd37kIyKI9T+vsiTQlf+h5PxQTs21dc59GSYneZBS7tyaiijI0t8SEwf2oOZZfGeJ5nI0M0xOhSEhq9I7h71wQWaul6KGt1kNE+vbvPHL73DF/svyfedDR801LVV1ejeaT9ErOTt0zIXHso6tTmPUvTRLxljYoyLA3ssC+w32zPIZwLGRFFoGRytnDdZhMyd9oYO2J96jNTuOeRz7BSUmvx+1iegmMvEel/qmlKcwQCULjHXdLjg133hStXBw/6eBze4A7TQEMR5vlh51aCw2yPJrWIHIt4OsDaHSBuxRBRWO6aBZpSsCtjusAvBjqQLXNW+ZRK112uEs1ZDvlq8w0v+V03F655heE/RkV4OHBrsm4f0S3W3fB8aP2W9zDgTqrHuI0+BHrUmXpuctxidRjQnYzqi57LclXJLypFX/2/LlInzxFqCwiA5NwvfCMqA1jMfnIRPC8mSxs3hiqe0Xb1GasQhfIZ/T2Od9C0mEUINa7A/XT4xUcH3Ixg2f24ac4qiND7sas95T56xxKX3W4Jnq6AubD4OQ+UdXEG+tulH3BmYZgpbKmdYzTdIJPJh6m/fH2D0Ni1LwUYyYI4SF/APFA9x7ZB837SmsHx9fLhqgLWUySgGgq4bc8p+7FlF4hl/aLpf1gvqsSdcVPekxdLXWP3IhkjtVy2Jk/uTnPU1c583c8KcGsC6JdCq9+mKLkMP1VfEnAwYAa1cKAIN9qc75A1XuhFRzDc8udrJp6z8MDCButdQhSNIbuuXmq9wQfDJ8w3wmyBIGYZjly+xUZ0CFHHbfAx/uEFHrPgUhhO/Gd9ssHP6hqZp8rHLu+ek7tdGd2eSVRJH8PVxSr4OHZYVN4NNx7fsq9kheoQ3iYIR5Xwg49neA7IB5I8RZJaAUYl1Je8F9ejTe/jYDZAovajJ+zN4UQroR6xIDcfdTLlW4LFD9sYoqUr6uOO4ieguE8tNTzZUQubmGlvTnHz/ayrG4LfJ2aJMJoaoN5n7O6Gs+HYwKdlrIzl59Kz4dSEW6Kbn7sZfuhge9QAyPWdax/MubFkyl11PXk+Zwt6xLTfOwq3Y/n0lVZ64zuegV4MO98Il/J9tMnrY76YtYX0k9s0v8tRmfCGN14JtnDMf2ZHQ5ctCWVV1mO0k97wATeK4JtV2rjVSCDdPuJYyBec5Qjwnswq7R2YlXHV58+ujDj3spjluVluKpQpP41c/YuFB9WaTXmvXvo57O2yK347jHTr7jYc7PtTxZ+P4ElXhF1DIXiUwYJAIhwvgkVH3+e3cKDeeWITIjO2AjPX3lSdNws097wME8H77hnyWD00dPAqtn7uEBc4kK7PT7ONaKI8aP9lUXATcH/4ew9tlw3kq7Rp/nn8GZIgHAkCO9n8AQIQ3jz9DeTdVTHtFqt79bSkihWkQAyIyP2Dps/AIcHj267Fud9ap2++gakmQ0OyjceGyEem6z30tm/ZgADmPiJzgkbtn3bWVMUPIbun3UnBZlSbt5MqwMMfJ0eRppwPuiN1FnKc7Adj3fNCQacMqef5+DW80IRp+3tqeyHcY+BPtKIuhYMMfJ+z+M0NXiqk0FrjZ9Zl+FRlPvd3yVcyX7Lx7hyWxlsx2Q9Mu5FztwPnRvqm0h0hoxqlhsnKRE8+668RRHMrocpOJUhbNB497WrBWbMxUu+zofOIr/kc9auc8CkCfYpP5HkBkeTKJStYpznvfHf/cDXywTXMnS75PoLXqk+kctFmP6MnTSIyEwvpxy0rIKD4P+KWYhm6azUQzjD38/pT5+XX2kMTbxuThqRHmYHQeCH5ZxvANwwv3l0bnAVXWJofukmcP+K3wEad/nDl4vAjJv5EK4D99eUJOFTd7zLspX9XQaMYTowMAkIYNCWA2TMnIKjqfuHfvno3AN5vm/f2oXHvrTLiugv6jftEn7wnV57jvAti6EiMv0SXzJKo/5uTXo+5NN4K6PnlGX38nyiWs01v9eJmRwCLHUxPoQx/vb3KAcKT6T/VWsinn/EICqYdidwoecOf93jw3TBklxqJXtJf5cHUr1gu4m10h9ieRCWpKCRk2up2v+ZVWd+ZdVt9vxWfkbwfBjBC68wgqf/huy5SgH639oaf+eY+/idJyeBK4OVvDCW93dd6YebCO3tI7QeBKI/mCpiz/bRS4P2BPLzqy27f9V4W2UmTL/KGmA5mrAQf9T6Pz/RJlXofvZXnx4+rJSYx5r9z6x0novLUdA9d4dLXW32SWACDFuj30rriwsOMFbpL4GFOOEvKEt6DdCYaI6L7b8+3+e3Esxa0Bs3HpRvC6HeLkbdnIe6oPrf9c2/c/ylA/hJChPZ84gHkfnhFtl/zKj3YA7hcPYH9vK/rwnhSH6R3JwFR/V3ti/Ol1S1Jrdwrb/e33YFTpz2ZwMx/7xzwK9hHnOh60q4xc87J0T1PD+IEZl+52EiFDppqPsd+93zcAckgxrQPzTENbqs7YtvCl5tZr/4Cw0++BI+D3dgU/E3WWWu+LHYIlJHz2E228s5Y757yZKhftu/xe89AXqWzHtPXbRfnoWENpZk5F34o+rChmsgInZLIN/ywMM8mSYCN2tz/3GOLhVEN3pPDJExzDbTSdDSaaj4m8YFfBlq3KHHFPaXPIsIdpSYCJ6JzN8jUAkB6yHoW2Nyl+8luXOFoLaVHP3IeeQ3/otCwwqBQKXPzVzlYaAYn9uI1H/Yvlf9bim9A2Dg1Jp61FXjHGwks1+G/5n3AT7/hBoppXvxKfy+f3CI670uCjlqf57/J9RUvCHPbQkrA5r0qw8tZTqEfmm/btiClZSCj+er3OiUcf8xZdKHuSr13qMZRjuhPs7+rJhBttCpb+0sqwnQs/RUWX/0Bq+QfR9j5h7YzF/PfK3OUJM0SF2XswuAdKlJEVdD87/+R24+QK8e4HGz8lPrIYJ8KVNXv3Oh9ds50PhgMxwkA2Znxt4BmVOje4bfWH5/3WD99/RsXh2AANkmekEQQY97T6IUNXTiLcrSV0t1HU5SaW5bFpU0dzRf1CtN06ruH3B+87vyE7ba+gQAqd4AtKKB/a4o92vd6L/6dHHwlTuX1b3rDF13vR9N9s8fXefxArCa82zLGb7hWBaZxWuO/KLBHMnjeZ1DTbK9ddceCzM5e+CD9Ev+B98PMIqIhLKlfvtouAv/MLkT3xuqeD4D+4+6FoGHXYw62TZXKhesfHB/0T2ZpUAG78TSawRrpopp+Tw/nYVG6VamhlydulEUKcvhS1lOS6xiWAukyVzmyctoudwqnITeqOQ8cRwHbJseV/DjUHCKXfVDuGCeQqJT+z/3BTVlqI2Nxo+HnzXhIj+Yj5P5yNmy49BTIaVvj3L7xnv+6cexFZ61BMAg4RIHsD6mn+TAjn9G/bOL0UTxyUZfVb3XXs0ui77PI2xFmwmuDL2kD7xPILqnfWoou0/G83y+ISj3l3X14rQw1FsMB5qqzcGs6nhuW6sWa4e+O1aWvHj2xfj7ybs31a2MI56URmZSe4dVs8wDSCo9Lh6br8EbDxbYb3AWeqam3+uS+z4xP+v6wHRdvyvEZDrOcWK01jnDosoyzkzhivb++RQwiiKIfqwYXb6Ogcv80/peFCmC2uFxIozyXeXydM3qalw4s8uE/AYRbveiXz3i8o/f0dK9asyLOTOlN/SlQZa1vwf8T1lDvzS73lM21OyT10JfCiHVrpjo2t7lZOfM+HsY6WtIYsNqkCQJR/JxRM3uF186a3DmxEiBDfvW41zW74PFyudJTsi5dMXjkeavSUqFQGAIIrSREwrbydZdEXZw0+urzCpUg5JU7L0ZOGf9vaSWdMXX1dDgnhA4OO3nYXtjV5rgzOvL5NPp8vQedciNc6dhPrvCwqzfcxB+jzCAywOryuI51Gs/o3qeqJeuMKFPd3xwXqAqmtL/ht1I06eervWZGnjLvV88/p5VKB2KDfiLnuHebJIi5KUnJ3T9fD5tF1ejGUgjVoOzR1/fp2QYWcays+agrUBgF/KJgtMntkaPbBFzYq27PvjnsO7M8XIDBdBosE6QqI+nCj3EJIniSB13XTcOtYRU7m1U4Zxu5OwTDSMfMMnxhk9sbXxNAO289O3U6zxL6oyN/kzuxD+e5U2A/U2lSH4TP3W2EC4fjMvQnWNnK8UZC/EfTEUF0CggynaFluehrmL8JtbgFGkj3X6upAhRMDVt/Pz+Qi0jxvd0kaav6e7UXw0JOV17o5grWOwKY9j6cZ6oygoWyVIUhvtjznRQ3BmSz96wkB5oPZyMoRoHvAj5dO+icQxbLQalyIwq2iNKVxuqdft14Oxn1jBad+OoGrYEjmKd4zEMocL+9g8KdsW2w/UzXVIs8idxdkhE/49KzzD4NNwkinH/6UV8VoMZbddB5Ebtmr46awpuuPlndNb0lfkyStxYqEfY3YgFqj/Rk9LsF++QwhnqjkQCuinExzkMsFcOp9meJ4JzdvrXmQNvzEaaUtqxha4RoNXRns1enPoYUUFSVXbefjIf08XR2WINPI8qukex9hWQfb9FPsHKuo5o+2RdNSm3NM95fk4ZBEUpuxGsjSVC08HmhNgZA1sGo3iT+3EAuAyABDp0NUXFRZdIKfffTuHntbFfTfnq6sTPqhWX+1RG+eElhtanwsJrrDlb+zuG5wNTMUdicmDXP5s0BTx48ewnplP+XNn+/lXvJlvdrADjJBMyab7pojA0LU1Ny9otWQ48N95TklI7FzZB1ddiwiKYtqEBqVnoiIBnjZy3HzLJX2ed+fQ32PbZNoBFhyqObcaYXOIkwSiUYr+3AVYbONM8LwuFoYkRXzEhYywcPw67cOxdSOE0FTI/PRfGnwx4VYqQ0uVLcep3lRgkWa5GXc/z1BMKl2EBBmLQcN8ff28jfjIVJb8Q9yd++YnQSqW8s1HJDQkW6m3Ua4OIi3/mH0blNGnlEZd7xZoDMLXQoJK/ZAmFl7U7j/GKRecnsyp8vngcrFwQ4GzWyUkc269XnvueR8cx8mK3WdQziKBtYw7ndo+Y9GEYtIMxbJQku2vR2nFE7jxiyWfGrh8k2RIk44810I1PnwoWzlYYgzEvDENDV5UhNJKmEWzdseO5uA4rmFdtojqJWYAeOIkZruF5HKhG1zVNUfyNH3K8ic4PLvCAoYeQL6PDif3HCh8FbiPMkMur6edv+b4U7rL1TLcLuIAL885nAU//o1fhrZxfPEMILIOLzbtqGS9FpfjJeoPxC0LyYZbHj+zC0i4VUpdHSfJTt0FRzE+GDOJ9ioyDBNpbA+8bUdNbS+4DOEL8LqLJTDF6gS+zlGjtoj6fO7N8Zkg/zbguULm82hDtiMjZvDIikxMYSko/dmEZbuiXrNafIeMQJenoWqBrCnuZWoXZNS+X0PxwPG1XtjANQ9y0KBxn1g+WJggER9oebJxlYV2SzC0ufs3M+hzi88QwSX937+rGJsCCTbO32P9UL8BzGoxTOeHj+kucqt+vymO7zJEbF8Xt5kKqU61/froX0XIwZSj7MrHrThM5AqrNHtBsP/0jpVCqZCuVjTQ8rI8qEbqSGpY16d1ZQY8owYDOr6C/nwnnQjRWnIartb/JLM+rdp4wYB+enYqRzDRPVJgVDs8f7EQ7i2xwLMM8ZCyejboQZPe5AxzzIMcp54GuIW9bSqc1vhV9ZhvG+u73OrSEBqYHFfLLmoixZb148kUJgVYs+3C4t5MW577TWX5isL02nCIQsU7D/2OVYM3YCOxxzPjyLx7AZ0WZIzMwLddesOcUYQd0fEfGFcvrPzUsF12eUl9x2rvB71z7vioiXd3y58/ItnlzYQRs1LgxhuZM2H5wKiAcxLvpuupriDP3gHZJ7FNdzoex2sEaCYJFpISydAAzJmlGEWNJCa+ui6y8YhuBOY6682D0h2GAfFHHa8GT+T7SJPme9QF9/nN9y8q4MELv/eKBr8EZrjWuu0I9G1/9Srhg3e8z5gBVB1C3fxDp0TxhJtJo8bf3TzavCVNVuNbo3jwYK4dhkgXhkEKWXmr3T7XLrnqHfZZuwBp9V7Dw/O4CSF5DVwPQISpVQB3iicV/6JD6zl7WkXt85S/OiJVF3jm9ZxbDfqmVQHgCTr9aiI/v1Blgyz5/ZXb08g924t6/IRs9A9m6/8xpfV66l9Rfbx09vTrn8TdYZXm8XTkxHzRZfGfkw22+DfNf9RkQKzsHSSJHTaH++H7E1icSIdgYRMIwx+Ez24rT3JBFm3/qLTI9LIjW61Fnf/7eii9toF80e5j58j5tEhrjz99qlENjE4hWOb7qUV/PqjufLfUsfvqRqodbQXHF+5fqM5b2LyusywnWPklpYP+ioS67oXiMWewX8rhrHjAVhunZ6W+ajSr7hz5vkRIiSkSamT0A4HT76RPxLA2mUtJnrUuwDDOj/zvS/Pl6qELo1UcFQP++NaEm8ILVZ/tlv9B+/ak6lvE/YnD81SwUIe2UepWIxzCFLBeUVufqqK/+5a3XLo/SpQoIY267q+qVhukPNlT+8Y5+6I/0M0O6xj3rO+Z87e9vfq1goOshBeORCNxuvj3+D3/HyYWhRynnKp7ySAHh8lSNY79rVfm+E3j8PFdS18utHhv/+vKv/2at+MdWQ8f+laB+6Qp7a/jAppSOjLIgH2AoPAlo3x6cZU6s33K6uYthupV6BTLlCY/7cVgq8ayjgaVW2P8Lacvv/LtynRi67g1gxW20/Re9c0KuhN6Tq6uG3/NTYRzLesrVKYkekHGoAbLJ+M3nIjzAXwSVpmqBybLm8pe3mu/76XY1dIK6et211JTXv6ievb4+vbp3hr9++3ztWH3yT8SXwWlzFh3IEuDqGftndumEWMdFgnk9KrHT0q0dMUnFduaHD/NplKnH4FMnmyl2N970KiZI+z/3CzCqT/XXXAhC+XMXYlMlq/W5G9RgR5t+Fyjkj4iWHSsWz33KbyMRvY1kAf64ew+RhJYh/Y34bv08aTXE3eKLoPjK2J2Itgz730wTKh3YKQJ3Y9VHmp/5B/7WQhGnbzUS0mLXmuhhxP3vXXi+YrhmJX4kSXroT9tSSQtI0hfo3WfD/2bi4sG9gzNPrR0Zh0fzP+7q8pUpMpX6FRKTT3rId0XWplzE4en4Jy/60yZoucb91q0ASMDl0rhqTI5RzFyuY/cjK+vKS5cVZXCYvng5i4f6b/pBXE3X5baZ19Wn+eeTa+IgSoDZ5afAGhEei0Z6/333VVRgnlz1Kdd0I6+Ca8MNtWJhf31TVFYuE7w6eUvRO96f+FOj/qw9+bvX0/VTqdwsWfszpwishCIyFgTY1Oitt8mUBb553X/3cKqIoNQXCmLLxU1klph7lAX6SQ/i90v4ls/V/MTnx8OpAXJmrOe8upHwW+XUf9tF/ctD/Fv9/50/LrXsxDevvUiDfEDvVOKxtI+j7R/oudi6V5voA2wYIrL8Lat+1kGVsatYWnnWA+Z/rVs+Ey8Xb9pbLf2rCdN3FWYSBvERsr/UzBimPRjvuUOzNSjZQv3teR6RrVxpudmlSuLtHNHLMss87Vu3RGCxfkTYC5m6K/d3hKST7ZnM/9SYwFJkX72krRh7Df+8rt98TpD43L5ASD96A7KuXkQBrrNf4qXVPP7fTIIC/zJLpS6Jixf9+95hMGv7MujOi9RZDz0r6SkAbjaMRrfSwjh3j3834zuDcTydvoX2v7jmdy6V8jRJxhF2gbt2jlRT8fU6hA/ErdO/e0ZTvHwiB3mxrnM45bqc+gCok4l8JZmFCyfIFM/NxOUeuYvxLLr1r/i3rVKIRZlH5tb/ezX/yn3oy6jkNUSqBGvLMqXeoLeXHOL2OSLjbBn/+SlH8hZeEBBSwSEvqnUc6grVW7BRrOisRXeWRXGV7ZqzXldVIQiif/qdJ+MvN819dzcchk1I5GEYkY0tBY5BJpZlCXlZredSr/kXAaGLhe4+g/1MwrL+8ACqAjwRYWVn5v+QvF8shwhwF6kcEdhOj+nbeNuq4TrMBvtLBvIvKP0lQMt/KodFIq4biFGWpq9mZ1Lw+sizTFRGVu50PU/dakNYYwVanOr0niCoYa6gB3BfINlnx/FrpCUHkE2Wked5ohlvkYCzAsbpYzieRWjnwmm9jP4Jc9EDQujlDn6cTgbrE2rLtPa/ZgTEN1hhPFw5lPvf/S7/ilgENqffbljacTv1OB6ItC4a5v6nL/mL1R+cDKhsptdmrfRfBnKp6nAJ7o3dXHJ4nyzLsITqQLosVs9swiINW76mVIjaQCW0xpKEcmCG71J0Y4efqbOauHDkD6+eCAimn/7mAeegB1wgb9y/n4YHuwor4xZNkNk84Ksb2ur30aR4YUb/s6cQ2NyyAdSNlCQUDoTyEEO/Al7EzloBqTGzwrmSS6uCYxe85+jpZYbRnikAB1sWU4x96Aq9UXFezO+k4aCHom12InQDcshO+pnG8hWFUQOZ2+kkCMRaqsldlOWb2NoRkiHG6cCsV8qN6o39WlfkHEd0QMZkxFH0+ToHIQT6Ctxc3EM63zk5+GnywWBp+jyddiy6GqWrHkm0PbFGtg6YNC067Gw1mfbLZgbnxiWh106jtfz3yDx3yWCeUk5gwb/u5sdzL4AfVK7vbCQiMtFsTvayA5KADlUSqn8zcSy+O5fiIVuUfNErIbk8pvfHszI62e0syZtmqCtN/xilTVfGRCTddRjiHCsE4ss3VtiIKoV+AWPMRP8Zu30crDes3Abw4ytUNCOmw8m/e3WJdDPYSxJ9i7u4nabsx+IimMfjAtvkrmk2z/PoJ8IURuFnXuaiThPDfobNaiTOK1B2oOqB16XRj8d0WfHgM68+8Kvi061BpNZrVPDZ6jj1PK+Nz/zGVCRehzkDMqeo/9Y+Acb2KK0zrLlHZqFY+6YKx47a+qryOjZd/8h//Wv/MPPOlsOj6wILlun9iNyI67qK1LcbEH7Xo3+/d/PrqdZnPRwvIBNdmkLpbCLr4xO6Z3p4ieZTPu98EARRFO+JgU7xKVfbS/8xDZtrsvd+nnU7Z8tZrDAWJwa88ZJsOZg/PZp1Cj2hX3Jekw3K4hsL4Ph440BJGpwCpHhgsea6ghNrDDO9uj0CJ+Ld+hZ+RVo/z22YyJvUSCYYh+5KC957wz2njBzOkKdGXyF/Xb8yhDXMj8z/uAP+nU0uE0UgFsc6AD+ZhnFcSIKwHSMWaL28/y3SgZmInxjZLti1yb24q7auReCzcg1P65k9j7OaQmpm98PGZpK7wkAd/kOKCsRjmvP//TUrXjccihZ1XI0mLLvFCfhFfH0CXKHSY983jpHg9AmDL/cZiPKAB/dLBie55YbdmPlSm71lVnWef2bKkET8WmE3b2I7z1dFr7kEtJnCZiywGsTeBjLQI35DckJ2Xq5eKyV427bLPUhmbHbv7eOMY9a2iou+qCpNhWw4/N5BQg8DgTsynMl/zulQJIvvThhjEnkYsmNc9QR7uYglztSy2dO/dYH8oxq+Mm972OapI7bNS0oepTigD32ycx/9O7/ioDhcAacciSEwK+GPycBL7aL9tK5j6Q3SDhWCTmcLELJWlBCfaM6Bv5dtqtfd1yZweZp54q6kZ6KX5Phu8R+JRovz+FgjyNM+U247LZj78aZ1woRzCFCcBpRxlAQSjb9rq4HhX5Zq0k/mTns+K7JYx9Njy8p6Th6O4wge0it2+/Jj64YhC+zLzX/LKRRhKui1L9/VZP3sKtTDvDL+M+WafMsFNxrMJeQQB9yC8SDemmG82708vwP7KSvKhjUGZ5p3eBLHqfcZCn4PpHxtb/tXKhUniTCUA63GQazZP3fhBPvap+HBbODlhbtZgujyrhVqCjlbXYXEVcD3rIS1gif8rbfO6J2WZwiplGFa/Afl02PXvd/ohI8bacAoSNv6WUsbzuc5We3ANAmpoSP2nsS3zPmR08HmdGfn6AuskoPTxwt1ZAFhGDe1p17VViGvHphL1bKdp6lwp+5i9OCM62f2HfeKnBchveTA57NWwZAfqRAR/C8Z+xaNkZZSY6iXFapPMUwaum8Fn8fxPI71PGpj+Q4g4mtcrNGD7HT/8XJ+k8wG5pDl9Cb+6vd8OoT6w3b4CFXJxXHYaeCh+24lI0tL0BN/R+MluLX+Tgav9DOv6jr2Ej/JW/FZL/DTqtnSeW88qQ8cpta85yBhcUC0TeLFVbiu/6CV/82XpZm8YFWagOSjP8Tdm1hvR/qqzMG8/y0jl20umWSLF7DFV4WqywwEFTXjbeEvmGZ8Ndb56nJWlr60WH1I9UPlxs5MfaxLKe9YMjhMsvDhTHoODynFFayvmOE8IDVMMZE1SQ7kxXM3ZZK/F7dSjds63j8ZPQg6t2iMFrFaZvKMAfrwhqSC/HF6I2zJco5DLXhmeUWLb607AhvxmfcL5zoXgeOcLLt3CXnkUPJZekXy19b3vz0rF47QASmp4ij8ehJ85Xa59rRkwg3ChCY3jy3dCeR9uIXqMR9A+fXPiQEQWOtaGXK7htNauZO0S9jqe8KSGwIjyHdFIQaCeKP8v0NUv6MrE/Zj1ivpai33VhFPgom1PXNcj5O7RxqQTg3kQdZHPhBzlzB7aCw7mMuSWXx9AnCz2AwbjN7o+TAQWFUHeT8IgiTfpbsq5zks1PcG0BWMZNrp8rEkNIGOwwmrZZySksGPByxwoq0Pcx0ikmWbuQ+S8IicT/D9eTIw6FAcAD0R1B31I5OiAQ7Jcia7qeru7ULcE9axxnKLaYTUCz4OaZTSmzHccB/b8d6SDbzvIciztVqyzNvWzd2K04sKY3qPA1P5UiAabFYXxH9lx281vk9E2C8pbUe1/kt80VLEZcvk0PL2t1oSJrlc+7nF4WALIr8O9H6RVahhRIb53tPj8RkfUYas9ZnA5wdAYTHFAuQFiydi+/818VRSrMvzrJ/dUCD+JZ2UmoHPjcn0ZBB35yDTtaM/HYkEthYnMlVaPj4pdBiBPt+RgPxM/RsG+hw6Y/2RBUnjULEO4P4RwbwbUIa9kmRvXVB5jZSyiHx9br2yHLDylqaoH3BwrAH3Xp0JZlbahfepVYRWxmqkkERSq8dlwTq0dPY8j8H6G0qpmBsacn2EQfsjo4USr0/2TuHuvaHzmfotw+vOOzCGftuDX/Fuw+PmvV7oZKZY4uucInvQExXXq8P/+87vG8eLLmgau5urIUsSEjyc67tYcp7nWQVLaJqMyqHcX7LYPhr3/9yH91OB4IKbh/sd6Gc0UUW4dijNpIIs49ZMvLOiaLoJG/0x1z8MIwjwY68MfTmq06rL6jBWHEFQPBFC2W/QD1ULn5foIH8gar2IaLMJ+hklnhGaVf0Or1bpsrPc4fzF6TPbCvI3z97qX3Wvqwxwcvm75vufmeePbX0pei3k9ZQp0yUO1gjmg9ZGUPGdaXEaRmX+ZHnsbYTtHwQmqD+WVdJiqK8ND1zp+iO11jD8R8n8X6X3b3UT+dW18ZOs4Js/a845oDspYIjDyAotnsATFPXSIAIsLKqmUmryOT6Yolifz09Sw+4BtmiI0AuEef0qdx86Z+ezByEB4N00gvUQbVVLzGXBOC9I7Glxtq7zTBc+82/6md85BvZvuEs0jL4tV8dXpIQfjOcCJ5lw4ifDalFZKvqvWrq/6XBnOjH9JXO8clUSYBKY0/ugihZmSt5Dl1Hp7vWq25U03W+OxRTnee5p13V11ckekKb2Nd9Px7mHo0fRDy8p/3EOxY/XvWtfFvL6IvWsQzPyra8sGmqNOI57vS76c2zc+0uw9hR2Zxo9nIVqH4oDjviMBlnt+vrDj/7z9aWE3UujssN/YkCB46ct+MtzcaWUoBCrp//mnJcCuTGghgz7RrJ5XUncN3JJEIAcwindZgDD+7QqyyK0Uob+wABElUn0g/XyDOqcxA23f+HlfVxtTgJG3pUgAui1JXPiye4vSAm0UzY7GLMymRVWhD3J7bvAvNn6L533hcsnazeyq6H8jgJdTRP75NfqT4AZMbeYeZbVbqp7oLsMMaQN6BGVudSswmPEpF0/j62ZRikASHlHQotCDju97yj+1vJlWYZBj9no3zDQSYZhb05B22wu5+blX9Tt3iQTglaylBupNhkc69nQDYipd43cX9265dAdy9GLRucwaQQCLmD//7veU+quN36uhS0pN/7afzgJsnz6RR2Ztmyhq+/wSdHpJcJsTb8dbz/qR1kP+h058f1lEQOUYNj5We8F+HtiSZJxAKj07M43+sbzQG2oAoWiJxYGlQafHGFwcoe7rt87GmDslBzR+V9oGn5LFTki4k2pCGLQ1HrzmuV0FCGk9xpny+v7/rjXgn19XPIkhGbx8AflwE11wzF4tAOmyO7fVkIJawZOxt2dj9cI/sHXDFYPwNKmZVKgZ7a9g6Pooz96UOfw9BsKz/zSP7I3K67hGAgHcbpILao+UVTUVSNYsVOrNQaiCjHYwRWu4jn3bUndAQV8jz6eAEiGv9kVzt1m0WGPXmxEQh9yxDrrP2cT/fBuPhRRedeQQz0Xo9dono4GIuodBGsLpn4UV1Wj1I21l5Mm2ydSsF08Gpj0ekPJA7RhQ4SH8YaehCO1ZdqBTJLNHvQyt2f0asjP/3oBSZ9+kkqAyZLJagAQwcCaD6C+7XuZ6rLwRDv525eRIZiU15+03R2BXC+bcXnOshwiYeMsDIIRPwZD+2S8BoJDN+gNo7WnNYrVHr7o7BHLly18yDqakLuVFacIu7LMVMEWK06xFobP5K8n6XoxSxcqvCFykJ+rc7uIRPtGoPlIVlkQYKnDyycs9bQ2ihSk7W+r0f7wp7nppvDmu87dRdjuTz5fsLNOk+m9DbCDw1rjf3VwAJrzPAlC0edeunc8a31yUnGUKrwg6F5NwqpnsRR657cJPG8UdaeQRElfiiuSwD667LzjgDB+GhPpjVGwA0q1z2DkHrCHGzTrr9drtenCcZzj4zUyVGWWz+Z4ByLE42gX0J8qUCH3UCvarOM8wcPzSiuudaPibATgDYL4HQm4f4Zi7anx2puTzlNNwxfVtxgpeTvcSrbwfkPWNaLADgQwCStPzH8RNf31NdfPCEcL0po8Xj0mJe61w9q8krOIVph7JdiHAnYlEE11vqxSfnwEJUyQDo7QKtavXMGn61IXVv/Mr+jwehjirNVU+wGY0YOvJ6Yy1/5K34R1D23wlBQVZ53GP/jxFJ3Yh6d5M93glMsZRpQ7n31/kx49ey8UmsyQVXowTR0Fko4H/GBhLe4jRQgWeu17NDDu9zj/MRP7NzSpMTCrmRWofxnz/CXyNJg3kgOAr832qUeQjIwD/M7cSCajFOzk+U7H153ycewdaeJUr9BDedgR7rJfCC7kBF53ntQPlsIwk7UDYeLk595sFOfCTCGGXAuRE1+19kLCLdGqg6IGrWJzvIhJDdhjBDsrJuk6qhyjKWUy/WlOPsxcVT6Txp8AWiThjLEF3z0Wnw4n2imGBXqQ0GGlgZwOaIXGAHZMb/I869c74dozecXmRKMsjp+nE8gA5qUQhRbsnlrMpxygBlyUjTPXS5qnktbmuwroVvu4rBIcfaPGem4b4wOjQyXYk9A/+PEpPE90PwjDBu/TaoWOeFpI0YfBAVzNvJoRKCuZ2TyZ/q4lE2dnxWQ2xIPr9ZoGEzikTLgHdG1It5O8gt3OiqFaKanWX8VaP5+M76veavtCwr5JFPOfw5ksGNL9ag9k6Gc2UOl8/J9nEsCZes87ezxkTlloO5HKFjm3C1VL3H59nUKOKoDMdpecsp2q/cS0w4QQUlnBTbKBVXeQamMYna9qKz7qiWjn3HD10vPQ3erkIs9jfCN1QP6ICWif8vk0z5phytuN3E1cTpLEbhhAD2vcLj8MsanYohtjldAVFEV3R5aNxwPgmjEp8nYGdml6dZ8SjlBdYzRMdUlUqAe5T3jSHqiK3PXJPvgXKhqc+Alqxm3Xze/4RhKr5pRx86nKlOUTKGSXRKy9PzshiLXW9evrh7GUA/rRmCjqLwWGYaxxUOr8yNc++NpDCkUjGjCr0scviDlW+7k9Uau74junGc6DqV+3tzrS0p1NlZnqu8S286wxBpcicHr2ARrupMeOAdTpxQtWI78iwivH7VzyuJRUqfzfWNCDKzldEaixFtUrci+zFy9YiUaTgvIyKYq/uLr/WRA2WAxUZh5s+CPHDbxnbaFpydimw4xyMZnsZIllwLtbO61Hrh7Jp8rtRf2AGALsGZWJQsI/SLwAz5BR8qhh+/oAUoFsMTQJIhlFSfoMPnwDp31MtlvBRTgkrV1uWK2woULYZQeqQ1pLwz558NeiZRag4ahkC4/TuBWJ54D1jdb7Qx8jcOjjuHSc4TxkC4PtmB7Kcxyt2wWxp+OQmnhXNQZOfXoeB9AH97uSVdQruwz6/joa18/BCZq7gMGAKfpsbrO8qd5/eCYFk7xWGl+hWCW7cjRCyEN6MmiG698i9lpg71/nlVwtGVa6677+vv1fZ6JU7v0zkRd37UXn+iq5aMMwsoatENjAc2MgBnDGhSFXn46PpSnc5ksmh3tKZ8uudLgMCdT+spL3lskKGln5wydSPwyKH37r+fRUmA29vpsElk5Jyf1iOcpp2AveIK9pddIZUKDA87or0nw4RFRMwx7IshxlfC03T+5q//TE0cQKrBC083lRJJHSJlQtc29cvt+KwnVkBbvErAO9U7puiKyE5dx2dkkcqOCjSoUZEiSeybEDVibKaaZp2p68UQq9A1W+EF1wHkfcZXQ+Y/m8vXVA2FQp4uuI69fJm0ZPxrsAnP34HR9i3RBSTa+t3mL3uvvCtMmB6ewUHr/W6ffXF2/MCBodJ/r87sVGb3gHbqJYMdpeWHyIfEnM8mrTXVjjChZetnPjngvvEzA8GLkKZqAd6uEnBr6WH4nFx4H1PR8SwB/RYQ4vQ2jQ9XsLlmx7FhelfF75KyILFmIJDcc3Nmc6teO47IO+u1vmvND7qQq64+NMzvRlJc+q25G9rF0fuFy9jXfQ3ef3mHws1L5GNN1OBBr467oU2Yd1kbAYQdy2LtB1PaSKR7aP8hThwc2T+tu90E77CSOrgQxEl+MFIL9PqPtNIIqC4vF3buFLk/DLhm2vb4o8UcCYXd/45LgXpi3xuof3hitvilkK151PSsW1SBnPzcjjVe7mLr7Bu2kT8IdNpkvWoPsstK8hQGPCr+X6iZ77jc3Hl10dbaE2FWalsgNwIjTAWBECY+kogNDuBJOUxCsHoFISe1jySmeU7Zbvk9wAl0X3vVq6tJ5x+rkZR90ssVQqFPg01NgKM9Q7gt+cRiAoikMdQFwhnX/4uhRFSzsy5kK5LRJBr0Guh4cnpar17oY7tf9cDfN+PXj+ko4JrOfqmfDr7QvPOZcq1bvgHtR9pneOcsyGClfIUu8qoiDE0rzdJRBHVMeBXMMzOIheJqh3/y75fbuaRKo7pxAbOMkgl5Vz5+UzazCfAEysLTKmBilpDHxE4vsSBMvTX15Yql9lwu4XD38mw3UD93e58MJnGzlTKlVYHbu4DCPkPW0XufM6Iv1Kz3MSVgDpzNcX94bHDnzNpXlaSXO1OwcGvTB9sZNAk9PLaCk1+4ZMy6h3sgobo/yxEt9XghNHQ+cQexfT7/fXeNV4yWSt50WcmybwkJOrb9KdsvuBL8Hn+Mt5WVuhR3t4LLOm606Po27lHsLoxCf0IdUWherCKxmKZBe6zkMzJ8HjIpkwK/a9uhxGSr4rdLdB30ppKcfG0J2MtNgPf7gvWCJB+yibweC0DBh/GYgIo67+cBzZl+s2t4oHMdTvQX6fQjcqmY5HYeuBvZswoHg8tTn2FG8sj6M9g073DrC9eYKV2uBcrdNEhgV4ik9EYFgPsG5Mzt6RZkk7erB3LcCgTx6mvMQowDDP+LZJ+2+SxFG8DSQpg2D47bvfkuQ9ZfOgRBhFFPO8blqUMleYXUZHN+te3PZtdGyq9+5oJKThvPecn0BC7wYKw9pkIGO02NQKF12f5XY7SHDXe975dWMkqsUHVKMRjc0SAmR1E7+jS4g3X1ocKHykqM19WwK1Nn3DPqDeSgBBYcMCGxrL3L73/GICjXlRSV6Hk58DA0p0W182YH9CDCUJIvRL8mjFGA+WAc1X8jXHj/sGjvo0QDMxO5Ny+ZSVcWIV9k+ZQ8DBOPbCOIk0bEan+Ots/aqT7Fx4PGPRqF6RQgxP9+qHm2WFd/9Rq/F4w3TAx5/OA8qoIh63iDP0+0CNzYAeI1FT4sJrZQMpY7IIK8QH0pmg1vvp68urcIDxxC32M/MvIVYYShJpdsGB0X3WWyOSChpkxkoTxFEVCw4MxHt7nZl3uCQwK0RBUZ4GLSURn9BJAbizNNnvkggfj+u9vd6aqsEdmS+aGyM2uBivQYs/1TKZ0XvrdOEcITVNkpxON0tC21f6OXCh51mIdLUG90UHrZq3NHQLgPtEDVwE+klW7PS3kxj1VQlQx3sckv2l/Fg1xxYKDnJFTlmP1eEPL/NgrmN2kI/SypG2qTIJjbs+GrgMDfCPI8UkeN7O2LIr/eCVnTyMvKfzIxDiEXdFKyswn/ZHsb35Y5DF7kyuBoz2JHHfkJfwV0mH9yeU9v3qvM0Hc92I1PhMMK9xz2gfFProUjZm6N1y0puFpwYc1OIOfHD5U9qgfeF9TlV8IPKRNvXDtZemfauAcVYR/wy67Vm9yZlqEgt+yIotYJVT0SYXm3NTfHvJWoBXK9AH5LSlrN46ecS6G3HJRDxBBk/diId8ZdTb6yjm4Eb88LjhiXhahj2D1QiQmyP7y6pGQFR6Xa630OqJNPZFoBd8X3Ptyp28t3Mthyul35WPNYF6vFiu/qA+b5c7kPOPZ1pWbSZ7P+/a0fC8zGcjoBrJSUQ2FtDwqmgXdga8wGUpNKmxEeMgrtaEYbftuScoUAdv0Qei0KW+VcoB7NzyhJXqZnpK9c6uBLBSBP4VqOKKhHzeDUpdYCMyCmwZ9ACXzyT0jKbKod8zXzOLfQYXRMYD/tdTZ0qIaV6fMXTooo/7Ifx19jVl5vEE3mq3qqUr9OTAOjn7ktUndUNoqGfPBr9tZIqrd5R/D3nZNZcJdhggBN7smtQt3rOpJgzQN/1pfzycJJUbHUxS4XDmixc0a6KqNPP4ix+vA8K892xhXjX1V4TQahvk4ydlS/X0AQTNovw1Ap3Zrk84NM6NjvaYPVWj8AdZsSIb37SgSdBt1GG9p+lsZzThxu6JA5O8rfwOtGtexQjuuTYb7vlo9q7G4X2LIG96QGiUZqliePije5cniGph1TAR5kbQw6Jsp2LSIMC33W9FQwVoHBZmlO+uSXgEmSXGsyPqbRBPw29zuYGFikVEPT7dA1gYEG/iV2HvousQuQ+oU4ESjTlyfv8IRvbjbhpz74qjYdMln4gGoDh4Lo8oUb/X45MjswmjPSQKYozpvWPb5bHnOP1ZQYPPchJ7my6fFk562X5DfK46CTzQkJZrOrfVTvt1/dTkYMvtORJhtVA167IrHyrLNcDz1HDijEkJ/+mw7E6hs3G2AgsklcoylrgBOK60JD0uXTBGEzzV/iAm55kNE9y3Hs9u3VVAfdehKPA1/4ErgJy97BT2r4H6tIosjY0B6X3xPsAX6xkF0sMuEdgX7NIfViGaSrmBrxCvCSee5SKpYjVkl+XKOeyeAJyJndR+gya5BXZrzvDr5ZMe4qzyEhhduURhHFPa+Frduy3V9St/SJzkKa7Urx3S49d0nvTu3NmD3KIejSkF0Zc6mqjcI0PJuUEFqyPVVOS57GZbmg0kccgmk2jT4RiqX2gtGjtPMpcZdEW6bTxDSW1pEt6Rse01tWL2dkk0BnmmFZE7+Kox1DAOxCyrwSf8W9yaAejZT7qY0hxkNgE2zAQbCknFiVDZI99DG3EKH0LnKiWTY735XDOs7+S+Vb/oUbDUQ59eMrzwEDJHuB/vKeIFuXRD1ASb1Vi2bnQngYgkbydVLyLx6A3YwVReiaovocWFxAgGBCDRY0pPg3VuZviCJ7QsgnmGudNxlqBhdvloiOqerZvl+C/0go5N7qjj/Mm7L7anvUCEngG8AiAXAb3nns1+fEbBYMUA7CBB2PCN05ddmpdidMD+TFcN4mtxHKL8KlY7MIYm+qbqZ72Xp3pCzpPa9TY4wxAE74qyrWp4qPglEyAniqE/VS9cl+GDK+rOyfnCOe/9znN7YMHOCaoZoSqlj/6nJbDRsOe4x8GBVG/2MeD3N65pbE8l1BBtfFh6HgPxj56mCFuYAeIOY/OZYaJ2wUfjpnMAdpuZYxW5vJzby1xHNFhGb8HpWy+csnyyr1W4U8s60hkhdUz4m9UDO/MqzVggiGGEpjOAz+DNbTgNc1LdxDrYmyRBi7tFO2gDyGg6mTkwYQ/V0owC5gR1R/MVFxPfQEzkT2v9PWIK4nbB0JtDWlvv10XPZpT42rwVS80TK6isbSbXY9PnbbuU/3E2L71QCtMzFMWhi6Pb41nGp8zf1cv1uuBmP1e1aPU7Ks4fDdN6kikb19vtDrYhfuce253v4yYOfpCLzglt5JFYI9HiKkprO+RXhVYxzzSEIJFhgpkWtiZGSQ/CtoPlUCElrXtspUtwqeLziPf3u349iKZ1yA3o4lushlx0XofblBO45mePoKFP/1MIdrxz/XH5uboQd1NVfklVB2omsvTB21/PenuL28NXhBmAgNDP1GVQadodWkiccsd+pGg4Me8sF8IzZ9GbcWFYFMdpvzQnFTZqVN5vehZtlWVf9BB0tNaeXYshy3zyT3O+tp3KKujI3GJNKKthfD1e9VDOEcCDQrvfxGResNNLQoa2NNST9+BaDcv4JJAazeL1jrb0Xg/1MZ/v1K0nKtPZfCmK+vncCbYwiizbw1aIN6bnh7nll7ndbJyfc3WvbcYQ0cPAVyx5zPKw4XdSb2AKp9u/aD3My8McwNHQW4+C6nexjtlBNSMK9GdsAJaOn7RiBGGWL4ErrH3kr9ozt++Dwihxk8fDJLzP0ecxKgoGQJGetej4UjwpJPdz1T8oo7Tjy5vIq8lPjKmDHDfpDRZfoQIwdLk6mOZ2c1ESf9V+xCDbLCEsewywY91B2kYBbN02BHoyIwl2wCKO3Wx5pupxICzP5sozVxj3P3rp1/P017Xj7RZV2/1MYFyzmSPygraGsGLFAIiMVAabIPEGOXCXv/v0D7tpDfXGrYK0rCf22r9PhwjA6uo95P3V2P2RPO96fLnuJzntff6JtaGHvr+75e3wQIpMq3Zs0tetGx+/XMSxed0/6WesqWq/2aa6OiefCXb0GhHqNqlq5d4dUV+wQYC6R3R6wQOM4om5CTo6RyeC62x7QnnVG6gtNu+cHbGVOB/6qdmIpQMY+H1UwDi4a0XbdEaxC4mq+n26YfaoGiF9pz6RvdexhlXhfzLsiLBR8+VgFmNiISzAx50UV1oUobZ9EW/P2bLqhqIBO/TdomF0OsmnR6NfwI0rjM5na4zO5vruagTszaB6J484DupJd02EZb9oQrv72sSFE06+hqlQiY5psaIoympAcJ+Day8VhFOw0Fq9f3wkRbyES2REuPRcwDnLskIG+u9XLxbUXNYApCwuVwDvUYpNernxc0M3WpYh7OARz0Oea7kIXQpsm5uLmMFu6VxCz0CDLp9Ze0Q1dif0j+CW3714L8AJmSjoic0vW6qz932mtzW0r1VMvl8TvEFoDZ3Rvrx0moNzfMS805kjtP5gR1CchPddkWYrFF4JhKRho0TXL0/b7baM9/dDsjUPjRCEK/9OBr/emm/lZZsFvz8ez285vPY3+8r3fNIkc3ve4pj/+C3uVetYmnwLN7f90WlD1w1XKIl7L/XE+LywxvW5kXLv6XKs3ZrIrMnIWMQe15aW6M5tM3PfeetKEd0PkvQFtA0icM3yIjSC6TmwQwx3RtAzeZuHZid73Db0GyVT4+0kEVKFiVNx8exjaZJpNm/1tpDtdO6JRdY8mRyf5MJHP9KRkyRhEwyJNdkn7RfgdHmBb3x2boaBR4bF0hIpks0RjRVZdCKLYKhLGq8oN6Q6ecCVN84TurHkFxFphjGKG+8WwFhqbGOYj+Uzk9WYZllgHTHAMNdXfTO270wrNIzKTWfUH00VLJuX1MKS9uM97WcYFBGfv3PUO2pSHzljouWduxMDTlY8T5iRjUc4F5DYALWdnMWDgdg9vzdCSLVrXuSLLNVGXJATYQzUbb0HY3O8E6+DTYx1z38P1KrGwAB1tdieJDnneUVyxOPyJ1JQbhe0jHex9FochgFusXNcx0eo9JPhsd6CkA8hHtYwe8vbf/ry/vomW0gvhF9SzmTzf0kXCtiC442+pcN0Lo/fGolLXblua2/ky7eMu0vNIwD3KnZlk+HEJ4An0dHBWqLavD5ZJ/N7HPHXgTG52AOu6xeNYt/G9FVBlGQuHnUp1EOh5eckyddRJKsX9LBfybsGQxoc4BgUHLrc6cUsVVaQsGtR5DzWjdR1zyx9io5RN6hK2gd5HQ1ZfVUiqvOvQnRweYSmfWKXHDFkp3dvioWUxRXAIb0IKw/7DHBI20Xx6nMeUL4cc/b+snO5rmFGlT6vcr2jK1EdX4cl1qTOieaW3FV+f+GzyyKNLKCJQ/s49OYy/g7AJfRVJneGwnB8Gm7KJi3PO31g1XhOkBGluq/laeStyDpitD+TtmNrpvn7figCVT5yqe969zUbppgHyYRJMt64vaOdGE1ppAdh6oRAG9n1afHeJf09dh3a6SI1ON321YkT/pPGTD28l3wl7Raz4oKvAUzI76I9TYbRPrBo764aFtxOLMqfa2Ru/ykb96vPjYLPvX0CEg8rL0wtqjlRLJli2bDu9gLIL6lZJf9TP/2UtKv5KBXFQlep9sxvDDZU/haz6dKyJh2md6G7hr7Qk/dJLyvLRe/NkiaOFfvLg9SUlwWUUkzcXrNq3jtGUwQiWZWQinS9iTNjkCMrkgzeNeHGNh+KdJ7k+KImFicfnYO+owdW6Il6S53g8N8MHXx8X9mLXfNndAXb/eHLRe44ADX7WQPUlk3RM3Fq7otyLAwwBm7ztJyCvg52SfLTfoerh+ZrWeumBgFMmAczRr8Z5YVwdn339PZ5BadirvdekK7GZU1sxh7XuWVzPAdMVIFBtaV40eBTiO8QO8EUJGJbiKiy/fnuibWDqddzbqivwxOYZ7wWolt8W9GGiYX+pMlgPivY0VE7VpVLYV71sUEikFcLDrR690R0NCSP4Mirl/mHj43ThKp8AR3M0v0FTlji9hzC3aKeyilQ54qk26CeuxEF5jQgav2YgTZjnJPRFXTTHgg745ODpsqWdPRKM+xmvF4YDEaJE/S6ODen9bPrydJ4cUD1mA5XTHj8p6Q8KreE1HCltFW0P+6RRuoiUQwjTlVyhSj5O6als+TOwDrONvSkHIrJc+OlS3VD9Bb1XDu/GiFj5mbn6SEy9C5C17fQqgGb7QjQq92wXzcKLdLxpqCVGFmMbzSo9NCrZHzdd/DU9O2v1z7ODUNP8fKSh++emsnIefn3JX09D6fIXL/7tJAVcIX/VSfH749djunuiK9/ManHUzYvClUUlc6fjOdgN94xq1yXxWpWkPBh3xT61jGuRpEplqovOtMr16hlKkp7Pufes7rgaucY4o8q5NwPmleYx5ijiSEx1KatDP3CtBgNVLH8aB1DpgYE2L1n40TBKFd5oAIS2dUvPGHJKXzlaJCrjwL6Np1+9HC5PaMpuKkAsUmNk0JBSITXLV/wRHsN8TvEDB6JPbUBJyl+a8LgkzciBpytGvSjibIVpvBT1eq8N0KwnYGViUjvrP7Tw2kvob+/caEVJDiGanO5I9c1vZGF0rZQ60s119RRcdLwUDJ629aL0gi3oPJi1hneNADU7wHtXOZmoXnQNBCFYlPtS2SG37hUunKQROrDMB6rQA0mq55DNzNpLy7N7XL9Uzc9OaCN+XFtDXqCA8lxzMAoNl0jSkjwcB3WzrDfQvc8bkLNPs+6onDigViAbed2l195FzCgCChgoFsFyLrJj8dmxt8A8fYojOdr1J4tZxSbQo2yiIehEpVRaNcTD+kaphLPBfHyMFICm/fuK4vi/6PsPZYcR5Zt0a95c2gxJECAkIRWnIHQglCE/voXgazuruqz9z33mnV1lWUyk0SEh/taLlZAMHoeiwQHYhSy+89nRLCecbUJW2w7oqhA1CjDgo57zKPFVN+4KDZ7HD8q1sgcz1vF996asPYpNL/a/33aTvGaqMonY9j+yhLvzn4mHji65JoK+P5YhoD0ITL+0rt2dqFYZdlKEG/3gZtVd41XmGe7v5fj3XQmuy/wF5P+bqIj2AzfSgGr95jX55KeRV17xfbSbXftw5JGllLJ2bQUCsC1Mr1btN3iD5kr35TRk5cbM+Bvt359tGs29+vn91j5g7djlQpvK4S1v2H9h7eLst1w6cN1DuODsAjaX1qf/JvDx/31JVTGeU/AxyYrU/Pjwqn3ZUheleW3TO8OfWuaLkki7EP0h6JTwiLHQfRRzk6iYZ55cgjHSXrPRUnFhJRzfmZt3JBT+qkmnCRhfuW+UCKyPm2pk8zuRYa5yijoSXMY+xw0aJklM051O8YGPpInyZDk91OsLhJAxEt6k08p2+uw9omchWlXz6zbWVmEfTP5hOb3VUsa7JhsLTe8/fEmSr9lB+bOsjb4RceL5D0aZUsrh8PLn7CLSmMtmNjFxhuhzYPuwWzls6VcCclxmkUhGDEbiZ1+kbVq2hgVZl7Q9jwgcF67rjvB7mSlD7gYwyRBkihYH77HD5/f/kO+ymXCVsNrU3yeCJSX54wQnIQv6YSuCWe4ckuC9oCPtpLuFT4zSMTUEdGaq3/NNuFsV+dP852sxWKSd65HDt0pjDnlLVoDDwVJgVUTmY1/Z0o1QExPcHZv7+cGcd4m8kf+2u//k7Hf5aK5B/339sUa67U7MJlcAvByKdKP6wPnihpNq2m+NZeP3rSNpsst3dYP+75zxf1+e1jNjfgsrKkEwj+//WkRt8ipny5xi+/E3xkGaZPwR/UxnweC8l7T8oCKhzTYbw21ra88B3QsSJeL072U+VTQzrHp9qvYUd9XiciMzupx7ZcwLNU2cTKxULfhFM0OQE2pwGcld8balu8CpDLaR4Uk8qpSPj/iR24GBGuttCeqGdaUCEG0oAuQKwSvAqaimprtjs1oM8CJw/R1PPxTeX46c2/a1GZeQYHdMcpZj+nIld1BTTMzz0ddB7A3Oq32aAulDz6L2PTsTtR8LRx364bv9m8fK8rrrbS+4QanQj+LtuhWSEZQgB+40bCiZj0d6KlFaYoQg8kp6te61I7HpD5MBAT9/uiarmYgahh9au+rcHOF/8mEZbESkjIWxeqbDU8zLZxDqgz5NpkThvRz5YrO6KLS6oDtySJnhE2y29stMBIivs/TdnfVUF57wdW98QgnYFg6ojB/VEoAUxZkMsjt3LX+yiQK9xNE168Ke4JzjcYRb9XdZpN51WrlD3+a1g93gLsoyc1jCDe/JZ2fr0FyroZE40/xo9ju6P0on8seMVnBZ+yOHDbJ+1MOe6Gz+136H5XRf2UELF4X2Cc9A6/lWtaCArhLP5eaTY4gEqdVnk+uVFInNWCV+Ou1jeb1X61CFTr5/tW/8ZofU+sjj4i6sBfMB8WQD1BWU0iC11di5en2Lb/tqoOf9e7nVz/E1xF+j1BP7waZ5DO9K8/yr0+pOcIGoh6H4J6CMPvjZquWd3Mn/PJWtXvTnHuJUJj0gEV5ncf64fhs3/MQ72Vjo02aR4OOAXQL60jvT8Mi/NtNNuJ/ZET+qI6Hcoto6FVdrdPPk/bjVK/iCXxlI7W22ofkHfy4P0PWgqM2GNbOaLb6wVfjtDvheXkeDje6E8OCZNamBeV9X0LfSUQzJc0ahR12dVmmr6DKAbMRbu/LFp/l44zdyAf+J+u3gv/s4ov1Ze/7kuvQQs4INqZpvOc59yQVip3YpFI1Gf0Pu350sGckf/WAvP7zjHDR81tdInRVywM33ixVcAvoF4gcYH1YbL7fT0aHs7g8swSaNYdTuw2ihjXE64u/Nxh/iDnQCEb+FrVaRP/ndQz6mjBUHmZjSlqsdyICdvqOeuh6TNM09ArQ5rv9DWaAqRLsfH7qIGABH+2KFbyaMeEtG3fuUu1oFPFHwbbj6Ge8hME8/CCn8ATYzWG+wA/0qq7ZMZstgUv+6rod4xneTActXW7kF4+omiichxYLFzOAbpKeLM/Qb3UjM7fo8DIRZvx7DmbDSirv8BwReVytbrF9d+XH46bL3DHvsGUfuXz/X0+bRg5xQ6t4+zjS/yH39p+yKGD3RQHn9g3GWPwZM/m6rkSygKgZMZKEUrMH2/PEo2lUzgvrqI3vLc2OuzS/keCFGsSqbMH30zt102/j+lItsMG3m+o7Djd5Add64zQHn4S11x2HCjFi/4aNVPTeTubj8dHvcwLtZpTxO/tUNZvZiocQIQdXWw/ov3CfJR6F9Y/f4ARZ5jawAM7x//Skl3vnB07ck4ZzjLFGAX8mUCIwu3PAanLGmMzVdcntEdNcv188noFvM+Ss5XyFfChBTRIExLx9B8sm+oNOFvMew2FrkaR8k4bVLG5jslICscNNEWp2FVPpMLCG0/tZbbIDgdNfehmYe2RjTx/9cwTshtneLMVIkMGMxydXW/4TQNi2AgCoTxO9wyMeQ2p4XYaaz+s5UsvEl7fGLeaxzbUBpbDXNykmLaG0Q+QygCyssUUgkririh/Qz4SlarNen1/Nxkr0dN9BoqeyJ8ckeK3i29a9VHXlVWrAKG0GSuJ0y+3PXE/dCAJAFr1xF7b/Vm34bQ8eBfgFLQ/OOIAb4LzbCmCEtY0brlBuf/0cX9TWh1h4Pk9E22/I0iFKS9FgP3O67Fla7xupN0duzojQ0v1ixe/3jypKBKJtELr13uPl+Uu3gxVmEFV01T5C1dea1OgceVeFn6F8TtU06tDLjck/M+dG0OrPdB9vzmYMcG9znPRqqDP17fiu+//+msJyuebgbrRf7rPQ09nShZqtyc4x0SjafgNoD4odphlqta88xNj9mmdGFJuXE/CIwOFBBnQbgDF67m/nP2XKO+xRu3MPphXAv2VrFHueFxL7/h765o7Wt0zs0ZwaBuw3JKc5ye3BWAFDGS33f3HCtRKj/R5aDkTMNLugJnqNNEMTQzHTIbKMH5yuv7kKQgmwC9b288bxxqqa4g0EPfpzVJX3/NDpr0z8QwOupptJcu/3qLENY5vaBuaGGFxrTysIJYZ19+Im3Lfe0+Abye3XvYVv3nX3hGlP/fY7R3wU8leDnR1k0rorC84kyeBbHBgkQ6UPNFxYQOqbV43AiQdxpGnl1lAE5ALrsmYRzEmQunceFbc3QkQsgAu8vjONaVxJ5OCgk/EdRUxcsFWyqwf0WBSGfYqSZ31C7YeXw2iPrhFUOBGZyEjYbLVkYZjUqT1IYR7RGMtLsPsLNoVafWlgIX0HO23YiYVp88E+nvE3CMxhv2H/9fTNHVnr1r98vtgrqMJGRVsYm2PXxV3aVkwHqAQKtYvjpwt/i6azLN/eO/e+QT/a/K84y2zcRZrQscEjlHBV8LFpbT7YDPA1ZAX+5W1OSS7e3d7iFWYw8bFLUn63bX+OTpJg3XS3xscWGki1GeH7GzzXxo1n4DrpIO2bCaIgny9JfOUfZR/1+dWgnCcaX1rrnfgGWoHDOpU3kwo60PbfeV4IMcFfTnnecxwzwg6DkW3fy2/Wucg/z1B5HxjLYbUazitiLnYvle7A1Y6W5bYh6teC3wvNxMFn4wBfiaPgq9wLr5+/qoKuMINaG3J7Z2XZRjJT+qCk5mPUUwGu2tViXJr70MdSHhBapW/l9E2iFBpP0OH2W2JwNxV/wz7CAOb3yRa834zVltbid4+LK+pFPiQUIJk7kkCuEpPmQ9NoIkphPleE3rP1kCJGxeKVGuOZDp3pvZkUhS1tAMALTCkWteHUVlnA+PTkjH7NucU481aK1fJdhjeLcDhgE4J1A2sQr7vrkt/ffWx7S4liLuXicITbn1k946YUjw214ztWnsPM9X93X0uWW22t+BwHsA6hYmxE30o6rjwl6aCej9oWyt1lgR+lUv2TK97msyz7ChR5sWxNZFgykbQ1rEWBWbW2K9VC6RHeFZH3j5JW0moZ5Kvt+PKUCe9swGT+5relzGuwNCUrNu7lve7hM18Jrjkz3Vf48Uq5DSv9J55/P762uVO+SNJdHFaLpgksi97mC7u6mzlCRPOnSnfg2NGT2dUIALnUaBjA2uSgDiaOJ3L5I9HJ1b13OlvlJa2DvufqPMZxIoThYTxc/UKGOA6Ds9ggbyHAfD55uM+P/RCuDtbWa3520VdH2v99F+3/tIscb9AVRgXmlaOgk/Erel2B3u7brF5DpcTrPZzEWEKjx5/C5/f4iTxkfhXEr+72zt/5op/46z0KsGfy1bz09KTs/Y6j/H7p/dFwxVom0+vbb5Fg1IrbLJG5pHDRdDhLELhnMXqZs0XJUxd5cS8A3X+nI3K8lGSViv0qpTMMHq3q/XGrl1CkoCbBxvzSBXxhczYa9gthFo8Q1AoNYRNkk/Cu4fmjLOyvJ7wpQWwVzpQAdmEh7MvKWze5DeWtoZLGVknhwIOwUuL3tSR6H7oKPYwqNt2HwsJqa+JritHpma9BS/U7p6sd4INFkYrZKaSmdJEGg6JpSt32gko8VzN+PLdguE/fkL097OY5zXnYN5lQMojTmapqdLVKZueuhjWsUX9uunRvKxBdJz5VmZwl+cDbiu5vZO30HQG5MsP8dGX75HWPzb20RxHwLBK743eAHpFErcCR0WA+V3zR1K00Fc79MkVGuCn0dWVmNt865BBE5bj2eynt8buCK26kGJm7wghEltf6IlctWJIk+mWVXyUx7sNONI4mQxLSJLmJLRLnoYct+Pn3x5MuVX2YT2rtPVIUc3J8nUQeJExOEOVNllRAPlBV4w4ACl2FvKMPU8rfOW5E3F22idve9IwKK/7W3XOsex1ny6O3VXllYT4yZ3QWfiIsNL9QVEaM1DnjlgliI9ZsV+YZfq9cavoUd8MqXTIC4TcoYNyags79I25FV9yyYJ0V+VfcQgG7hkZs6HkgtUo4IeuRmtjkOc2jTH7L3qm+lfBif91Sx/SjAuNIcN2m86VZShuiviKhC0rj4nBjYIMWRpV2zwDqCE7GiVoO/y1OdK72CMOPUnd0o6MJRn0owuEZqf2lUrUWG6+0uzFGfLb7dWNmhqucfO4GRInKLUr1B38rw3DBn5+Td4AFPslr/1DMuy+euwZuJIZBsEg9NQcAlDOAVNQ3ZlymkVjVzFjmmZAF0VbWcd5qmyCjjvuVP3tKhui+JrQ6T2Txdc2ToWV/m066WjRyKK3nrYRDM59j/JYu/hf2nZ/Hm6pmEfnKuTOmz75dcbeCRSe+fE4iSTcMSQf1I7JE629GtHPmmfqoTKjzoxGn3IIFjxgq63zzkFi/HcDIY8UuRb5lWrriG31oKvD100yzGOGdG8sgVj8figT9hgm7RcX16kJxyDcGR3w+c5ubkiie7a6YtZe/zJFISQE6gMcXaqdW2xCaJfVM8xzDu0uRqLQljhnub/t231OxklcJt17JGtLv6bq5tCEoE5VeV18EB7PsR5VZs1NtTN/5YbEcUgUD8ELhuWptsPhsFZu9KrYMZ0TU8nErVaNbT2ZFlDsej6OoP3yvL7otLoRSG3Ng50+uVG1/WG6iIfbPnGoHBPkdgwcXBkcWi/0XBvdBxIf2L+Uh+dKeaqVQ2IHuDqRUjY/WVva8dVX/W/bRVTuESwx1ipeRO5lME/310ySSyxWFh3mZBvu7bQI9mC8IzX1V6uzmUAruuACEhnUzFOOyHkLZh94B+we3xU/Vg0jqcDmoGaNnxt9PynIaZGooIxndQ3HJ0BxRvpnWKeSR7O+8H0sfCcJWwpTA407X8vYNGqY+x2dnflx56Zbn4LlbyykqCXO4rBKbwGNwCq4yVz/NFxWwEMfRNvJmOKezzRrSWiAG2VLgvsiyDbOf0hHn2P7nEQIodcTGVv6TjzNvOD73G/jJB/8wiXVMWdkLrskWohZ/dDVesIwJTXvFAp/B7tzGPm4NMhuG9GGPBfC+4Mg2Ew7sY9XOz0hOuRkO5xGkNRjWFDzmexmYpl0WaLGu+aLHAcEy951+sUDJbCXAM0LMYTm7dYL9kucfsq3N7wLAmqW6ODOIo3HtOJEzZiKJlxlsbKcB+c/pfAqKUmDxDpZDeNKktH6hAeZEKLlSm0zLPzeYjW0FArb1KQQ0FwuF4PjASu5rlc0/+MJC8FfrzGHRETjrLw/UojiyP2xsFHnngC2CfZuYCNLP0gnbdmCjBQ0TnfA2UnHqvgp+wlyC9WbyZWbbESEeFKxswJqKmJH0CdBA1Tpnrayl+oSd2WSWlY3wDx659bz7Zrm3EH4U2fv3xMuvpwgErw+MD4vlEwVL6YqUNAditulveRoeQCDH2WYJKtZKcvFMnW2QpBOlRMOe/5qWAP/VSrnJ0fNxKGav/PKLXeuFIerVCBPXBbAnBnu/YD48V2Jex5gYvBpN8AZNDX4o4+fWAFqTvRgqAcSAFNPgc7wIOJ0vq2j81WHC/lVVZlmOB7q1QrhMU2Rtss47laMsD2n6wi5x6P75XSurPtC01sKleDVi+KFN2D3R4DVC6h8XySLexxu5jcNB3YF3H1NvCOEsXqZ/8+n+TqjEsKNA3E76mf8zlRlXSSHEkFNDjmS8yysxkL9yQv90UOHpidumcPFcihqXso/hdYsDLYXdXuv5HuGNg9JoIPuAHrd4lipkdmv5F72YFQvnPigNGmXeTUmAd/xKk2QgzW8JVq6hT4S9/+eBvDEr20vf5LKamguoj6IUVJoNbvpScSSs3qsLIcrJKGxpP8s7IanKAFEFf8Sp/6Hf+pXRkFaR1zI4RzrsrwYxZOMAJ7cL1rKAnSZf1rAuabdlXZFY8/E2GrKdRSxo5SJT4SimvOLg0jzCTIrDdVp5fcEmw7p+e5Ks3Gg4cIrbqui+dhRV88Nwxe+FW7SnmHs1OIsu9LP1Ewtwr35htiif3WqvqQirKBDTdEGm984p9gkzjiYtNM/nSSnuErU36/c81ljc7jxiTb2i9vIf/Pp5wzgQaxufcqiPR3KF0PXCxzUnodDK937V9/72Z8Do11tF8q2aDFChEaITJWA9J5N6qKNQ9A5sxezAqoTLx6m+Seo+eH7DmFD6dF5HH6QR8l7UvIkq2uFKKgBgNa+nKawhHG5UHqGdaYbUQw07DT688nxCX0I6iNmBz3WIYQhb9xsy/4ZP0v8FafI3ogS7/aXTz5llencbpLu3229VZplPH29f9/IFiWjxPp28dOwVrQu1+5l0DGp8CFJBKL/Z8l508iVZWW5E/oRTEhPARolmr/R5nszcla8v8mCZCBe016zJnINtffj98m6jZtZVcTXbA+FlJyhPwbVUMxNRxA0fiJqwjPJh5x9VBz4Ftg7xfxzLLXd1NP7kmmdPxRb/E7H2oFaWguMw81qMkziuNjPiidQcryffLKFW4Ic6U6W1JRrs1t/5Ffdtpf/cpRqlMG2xAfoQOxPGmx94idKIBBCQuJQZUTY3KPx+lgs+nS9t7WjOaqeDERmNdyXn3nm1O/z0oXENB5tArqwbRLfzr5Sbl2/wzBOy3XwxtfpW8G2qRJUxZcLeLOw19KO4pyKdNYj/xzzsfStl7dGvJ2xMYud57VBA+IxIiTlVrjdW2OFtZqJ0+9uH+9aVe3V9dLRVPGOfH1cT2WGI1HCtazf/5k+n95zX0ejZWGyJGbRlTMQlfU8xNEYTQw4/jad0h2NMvBd2dCXOjsGP6weZTlgF5rru1ogZeLRnF6DVXqq6E43jFapykyPaW6ruStl8XOWgccOYALJvZSUpvUuqFmE/TZ4wqr37Bgmrwn9NSCsj71f3n6usucg1nhLeAsxAsKiRY3K7PR8s/k/e4R+Mtt/KRrNGXUzQsrSveldjR+KvYxE9Ybf0UxzihyCZMIbKy9hNoht9m9Q4IyQmV2yyhnzsFALrrbp65+vpvijOgkLXJsovVinlztbsSKUO5U28vJQB2VUVB8uJ5/TEzCwiUSLFGS7vQ4rpaEFuEJnpAPBATKxfq/5tp3KIb6msEhIte28yz/zD7NVRuOmarS6t/CmEP6fkpI3P2s+iwYWCYqSZGto95dvfm8hLuze+naynib8YgAa1GK+ahmtc5k5/FTuzM9+4a0semEZ3b7E3WQaG0WduGJqCloYA4fnM/KFTjBmJrAZ+Opnz4drRTHtx/DAqeQI+6+3meDbnK9h0johff4c7Be96xlhyz1haAd+CjgLwCoJW2yiZy3cTpU8AXUPIQ2CvvI1LjKVQFAjmM4RzcZOYbgZ8ZRB2+E49hfo848SUOke57lHu7p17YU749LhsH08dKkf8Oz96d+6Web70DDnd2jgUrPcFvaRq3hYLewwn+jyqQUj0QBVMj52IeGCUS38PKrvf7b/Vv2FWPaFSuHKvZTNN/yLEzjVdcC8cONrRs9WLPv9WSgdhfbJmCo7+a8uv20QfsIMTC71Z1/xy214awyR5jpaJlr7bGfh1sVi0lsJxmRnOn7tdxQXeFc5gNFU4izrobYxfDsZeLJHWxTBvDuWRDRnq57Q3PDLKxo1n/wBYnaqfX+x+P6dq2qe0ftjvF6yFPYVZUhG6+Wrl7pu+dXkqQ8WOLM8fWgkQrc6u0zQRSWkRkUBSD/HAnCfztmQ5si59gmCSbIOUOOAlpDVPD4aWijy+Bj6Py994OiFRPHyAR4RDhibY0+iPXtjWvlSKLzHJ/Kc3MdoZtmvIoHVJR6wc32luZ95ixqIiiHMNQbf873tpHtJ1/YT0uPX/o5vGOu7TJTdDjg1nfWzT4qFgBKCXp2/oC/Oliuyv7JoJjgPsGofbVNU1peUplxnxpLb5krdEaP5VM4Fxd3UH77XtERMLnzNdH+3zicx6YzWY2t44t9n0dlFc5UNVi/piWoeMBNc5EvNHGp/rnAWgHwkldVdc9NR2o8MbRaWNY0AYi/oBV0dew/fkKeVbjahaVTH3yNXx8M23UNnP2aMbyqfrMc/weHZq8kc5N84EZzTo98wwn8Qcmw2qsaE9k6VPa33Bc46MthLl5ud8vwCNecnZVDvD9GgS0R7wxzIpH0f95go6lKp1gJMEJ/mfd/Qm3f5dY+AL4Vp4cbcqiy7cQxohrjYNsvumrXC89O8j7aUwh3mS7BpA1aKwYSEhR9potXGu6yVkZHPzXFf81/UayksivEMRd7sB2ASgiXxESm8FfutmSO4gRNCgUhhQJVHEXym9TxV99E1jukRY5n4YiDFtdBCHYHmjdWq7tlm3Im7jfYq4WnaWLScRo9GvhtRVlg92AF1w9zVWsEZhkpkSxM5seZYYrZTAEHE6rWaNJ94KAXwj788+1Lez6W4GdBxdiFL1jjV52uYDGQ8i+Li5wCQGfxvshxunxqdMKTZjF/Kj1WQgVIYAD79xVesFHhU8EMtnwpVZ8jFiOCJSDMuE68tZEyrrSBanYLac34s/Kjsbp/O8kOVf4yb/j8pr9CDU+31QnFHdBfEpYLyOSi6O4MXP99Viuw2DPuotwUyjDV7QtI9ED8O8jEU0DV1r97JHLPGdPXxzmv012/5OW71zJx+pPsYqvPp+IDR7v1CoKQH+OHlpyiDJNSxJJkuAT+gYF0qJGzZMFogiK4/Rx9KGwlsesazWECqr7vNHjwKLL+j+cPVMIiAgXGCGV6YyEUNanPNuDfq8y9Vb+SwUAcL2ZQHVnou9Mu6otoqE0GsPM9QaRR9OYtFghYwPdyRwtUwjxy9eEUPWTCJ1VzGifTeTxJOWKLzsq6j+rOpvlPzPatrjbsLCJpn0vc9c892J1WFYstvFqNymANDv62bDJyZQ3dQjvHSql0jEz3+slOGM1C8zhQ6RjHg4eRhGt80YMoomE8qzNDo7SaI2g2X4pZuIsJWhDXn01aB+E4BIMO0X5qnQD6+n5asvnG8xKtWk2gOBJ3iR+y2us96BNSvzsaqKMPlZfrK7ynHFcX5hFU6scrIGxLt16JycyZd47FH0zMWDQcUxAi94q0VUDNIew1TQOtOHC8IsjhjD/UNWQ04Z2Y77s6fU+9PYr+q9eJthnWMoLzFSOMFR5JW+8byl7AaT5vDSULHNVs7fKflNnoQ6N8xZ/Vajgp38yI0IZjOFxQu9/KOKfetti7Oj6naWbn/bicqN/p72UMew0PY4ntXKJXiPhRuYOuy9Z+cUduBwPrHk3IZjSzXoznPpMgIcQ4c7U1o7pA7qoW5/7RA07J1q2zhTYbWQewaTL73rvce2F8NkDFmMGjWRrhHkZgj46HPBTjdGUbJrYTOkmOg36JBIV/SFM//xYvkT/8Trd3w3wu0/1OWV56UT8yyF9F0m5dX/Z+5Vju6S1Euy/5BN+f54pxSTeYyiLHrl3dGJorE7p5BxZoyAURGnS9ZHuXwCUaYoFThGZPS5S1wZ/nGryVjqdFMieB8xt0d43aY3YxrWtft88PulixHQGjSaTp9QzRNxJkwm8PBLbcfI417ufeLuT5iqocVztVs7+3WrvHvmJoBobcih1JMvXQynf+Eh/CXBo9DnGjo1SWbX6Rfjbo87d6UAfqQHr+iaBANZ857PRi7X0C3zIpzpGOexT/k9WWouSg42/1gfvQnR80fvQ7QO2L9KgT+AZ49PxH6L/Zz4phqNc0Dte3LdkwFd61ngZa6Nux/QuyDTAVVUH/y3WsRTiIDZlXWqFY9/dQidHHcT0c8ZDhhBRIeFgmjM/9dOF/lxKDzPVYJ1/FJRgak2lhUv3Y6bA/sAgMNwrNkptsClO9JjCuqUnA9fW2s4PUbvY6nBo4efWrEedma854PQ3V8pfdOwe8HhvZRhOsrefF15Au/V/AhJiSDGw3X3wcHc5cK2PMWWshn38c/F/YZpe6Pk0qcmGpoY/fyUELt66XVvjK7YaAwOKU5iYTSncNb/7CkANLvKD9GRZhS7TT8R/AntXeMePN+uYXyIJ0ADjfOUghL+no59nC+EnSJ6W+Lwmnxlf817YHgN++x9N0Sm/kO7j70pDm1JrpVUh/YGZ1qsryHSxxCQSuxTBjUF8PAe+KGxeXeiYkpEKt7pLTUtjxIC+pyS9mYOI02KSF5ItaenyeL2T7e9U8Ww297JIG+m/pwOq7z6xlebopbTa7zmVRHS+0db4l9762mRsPdEGu8m/jRhPqMj072yzgr/3p2n4cJL24GTGt9PjI1Tqb1yZPatsqd4a10MCsDx+5ulp3W1Bz3MbbVmPzJA01ojQbGajXNNJUHEDM0CW3tRXf2FyIOJH+V0DLeSuw0+fFvp7CDrPoclg528/V1WYD/hJOzq7OPTpKdyRCnAaMMF15rzTeU9i/4YSOgQyRRfDh0HpsOHS6tBtZHjZy7ZWJJU5ps0jbR2xs73mUsdxRofWLlPf2VdMbxV9ytllfm2h8+vny8DSODmBLzjZ3Y1RvrkOD2jgYg3Jupm0c99J9DDjtC/fGOxx9Zg0kTil3pTyq3T+KuVP8vyLF2mTXN5xM2zTDePR+2P/s+VEhpHSjVKMCovzl5EqF08XnM9b4tBbTqFBdkToN/lUjKK3+g1OmywXV5HGAjQPPFLzczhrwmREEDnGVM1spjaEucRr0jxdA0WcZF68oXVxbnZ/1aHcreikCScLyr9375cgN7CrgpCFfib7su3uy78Z09xdZdjN5uLnBtWa+QeCqVVPwHNzzYFLCUIwdqrE7hdsSAXnN3uJfLYQtUKAnwsBMxxm1WVYVeRg2AtVDAL5Qf6gfUTHo3nkKfesNzBSSBEGcGAODhgaToPFfWtJc/XYaCeFTBo2EHMfHsIUF7qixB0PmDZLFua9poc3esoHTqIy1bm85JleBI44dLR8AvLA+f2LhvfHRmKPjHAnubvtwL7FiuYqjnGT2dKvfE43/o0zVtqhGp7MXl4U52NP9BDsF2pSdkoStZ1LSpnTO4w1yAyJ/A07HuRyJx1IS0WeVduhY9ABRIlhW/3Dbg5rBlSjfAhRDuCs6tePF0Deh8mMEPi6+wF7cBpWhueVy4zb4VTo91ZbpNMoJtvpt74WfO1w2pWHBCra8b1LpUxN2bRuNLSGtQYNfjjca6c9Q0y5T4/9m9t2AJZWBfiuTo7ntUnsd0yvlsGOz+wXlzEHnky9/u/tBvK7c4j9pZ0/+6NNK5rlpyb+uItxI5uO1Ja5dT8h+mpX7OoQaHsRAysDRo3A2B4xQs5zOA7gal4h1zthoMS865VoviAn2Ywgp3rDl+dEKatgpM97kGuxX/dPQBT6uDhnX0RRkXm1J5pQ9ZloR4jOidZmN9/FEeCq6aaNW3wZmBN9fmsLLkdnvb6cXcmX13bxgDwaDAMcNtwapE5vCmKSNDqRtUVNU/ZGq53bPKnAnE6fr4K2Ga5U2dtehAnpQBc5DPivVNIIyvXpm/AEb2iRTGGsCPJV1afNXmSzeFBlzaBvcEAYaGX0eXSAF4cGvnTzbpC2uDcrNz3unFy9+gb9vjJSs099KVX3bYRMXaq3zHO8iv5vd5Zo3Nh4nU0grf+MM1g7QDPK2Y3DLzz9T02Bv3lA9PQMPB8eRuJQ6lk9E1kZuBn8x0hik17st3qDOrmKgDxOF58OnB+1aA4hf1tVuI7+4C4x2jfv+Iecdvah9DvMAtbPeHHAX5EwmjCHG+/z4rcetECr6O8yvkrV/f39/znTRQLO0xzZfC7+uHwH0/+bz2gqAs7JnalcfiiuIMYdhxJEr4HJAbm52XyPUs5T6poqpE4AW8Cv6UP7nhft8kBfABrkxoJ7xHtx93s6u2Vre/vI+xkaqbYBe8Ypqik+85gtyQD/HzVE7IT2rlviOFOIA3CV7AmejKO69WCrD3uK9xrcnSIWQDbsibDYUWtoD2I9/Ozydq8WeUOUPxdlEmj+0SD+fwA3O5pbvjSO67YMgn259uPh3gve7du+gQ6/XjM5TZ9fbFYfCk8+n1TzicYgTPpmCr+rE87yII3Tb/zfPseL51C1hZiDbBKT/0U1b7rbYDW1Fkmr34ZdHZ8NNiv7DCiWCNzzVBn9VLaKx3Gl2rU9voiMSxm18PWgsi9fvX6TAEDvLnvbEhgTY85cM/sn0gKOXJNVA1+t0X03GYTKiVdtTAIeCDbPXRupGgpR1eWPeE3XYqjLph+CRMrVF7CiYSHFYuwrGNQYFXTdI8/zKJn1opSOexRqvbbRxvxpdqbg/ehHpvoSZ+xZ6d09PXulybydeEVAyfzJfSwgI/GQTxJsXyhE8P9hlfSuMngLApBBMtw63yyW6B6AZefXUe9CGqgZp9iM3+gD7bBcU5op7jfGgLXjR91GVm56mqv/YVXHoKq6OgB2zmhwc+YWxKSinvCi0xX+2G8vlnc/K7/JdwD6GlfDZYwf3z9lt3WsZiUdNPS7ngJqQXibQFQHb/zk1nUokPaqM4c/6Uj9uPfNjmomeL9kUY/mVRpVKKgpvtW7Wcex3GCXozky9tFM9blRsi8cuc9pOFKWAhYT1KZaG/JNBW/LREynSjaziUIPErj1iecvBW/Gft2PulRQpsEGLbpghP26cMucZGVSbUghIZWYzIRJndaL+YkikLeaXZgqOWTfKBeJrSrrOhvb/Gck6m28RathpLy/fQz26OwLDt8xDGjj45749Ejsr2VdB0QEYWYVkXBRHvLqowhKdAYG9ZWBSaS4LwXesqXDTOitVGZoepuxplTu1j17jwuX29o3qc9tmtMF+l7Xkkcb0YwRYOINVufTS9UOCZDPAIpS7sMh6nyWdY5g1Olpfylavxbw/Yv8FRjZUG4yBE4X9r8RiwhTSQpzBCWzLw1p3zg2xuToQrwAhh4LH3nnn+Y3UwaWn4UEIaKerQxuTm59Pgu+meDULvX3eGZA14Wk97zB//I/ott4bSNUw8iE1ufi3V5QiNQFcs8CBrqoFs5jzgv4Qt8rYq14JnQdEQ8IltM+uE9+KT4U82TuMHa3dMMpJvwuyVh/XHzezwkFSp4SwNZ7kF+U7UbURvzXG4vmQ2Dnrak/xzJwaco7AT2J/SbpWUd008ovqCuPkhK2DlR4xOM/DgRc3jZSqRCFThwoPP3kmvOYs2Skk2wxJo+avvQC62KfUeUPlD7ldCGSwo09mpmDDxdfHZT37w7x70JZrXTVaiToQ+z/6YETBsDsLHxMkeB5ZfQrCvhuLWK4isVrwTOkr1t1XJCZJQJEN4mPCtzly5H7RjUhCXM48OumxE8hCAYRjYxtGHB3UGDtY6HmuHvk2VkFXAFX4KTWgowLTj/8FSRot0BN7tPuq9pfXNny2aJqGh2RWH5Bt9pR+zXNW8kR7G1Zi3YA16z0ov1v49eLvd5OcVYdUI62w32ZDli9C1zta/5Ps7UBvUR5p7wGx983wB217GHTvl/TGXbRdsjLcdYbVA3gR7SkZduIB41mZzKRf8FpjieUbN7I8I69Pe/xFattRhOhOpEhO0xirTaRqJ/AtV3ZbCjQah+EEdjRpqSe1895LX9yQ4oRht7PaGuj26XW6VBSg/FrEUy4dQIe/ZdOGO1L4nGVN27Vm/fyZnlgycNlCmMX3YCHhPOQcCV0qKl8ydDen8/HKq5nIxn+e4YuUI8puSa5jBC81Nq5CXZOqaikL67k4RiiE5NrsCtsBt6x5SjNEZbhd23sGMWZh9ggxXRf1dN/SJ5FvJIWL/oj4RANOe97+OIDyCcc7fYgtXU/us6qzy/LMAUiHp2NrVVU0i4aCyD6rDqe0H71VhIV3QQG5VIONsaRK51r9REc18FEQkZnNvUMa30bn+cGdFyiay1fNwr6t94VZ/eZNlmlpXbBfGSZM1bcXk+eLNFK9Pjtv/C6yVEaL6aDXWdEFW8c/tTP25z79XFSAQOOBhIZUUB7M0c4oF9GHy/91Al8Zp7UTWXEZE7iR8Am/DIy5tgk8Rb8HC8whsLf89bFJpnnzofLJFcgjHE4MX6aVe3ft2tdS2mBhm+F/zeZK2kf+dlDSe4TsCN9kz6PDzSNoylSD3yJa6fU6zLT/AeYF5F0JZWmR85wGAfvbXBu8cvqlq+Vsxhl97NFAOjTRceqt0Mykss3vBuTIiMoYmKN740erj+n9sQ/nQfuVZiLGFiAB9oh5PkCtNEkDAyPxDoGJInrDw7pJn0+h9Wb9/ugP50KNcF3G+K4R84/alBurfYux3lHeq8a7+qFftdo7f8P8/pcTfWFmXA2nxrTSJs/Lk76J4pNN4KzUud+NLsOTVwio2EWYvXl0e+tQSxCUWLbVoUsa14y5haIgFpANRtM1Kp2lpBcUUvzBzWBz4PAEq397I6XfDMq3d5cpsvW88UmTD54c1oY+Dv7xe4NnfAYo9itfYA7MP8K2Mpxoihtmbm5earbwBXVylmOV/Q6oB3Yqor86WmZrcDuzhpFuXf60DLSO6E5Si7fOLpxGPMOd1yjqsj+86//VZUdattTC+yODs+Wao/NeYlp3k0TZEMM+DAKRAxkdXkXmOmXG6zLkVyXZRehd+R05QX60XJ+h9Ih+eUzbLqmiw3+3c+wfX6Bs4L+9Xz5MELlhfYunHlvrmivt+8/zrFxxdoEZ689/LIeLZ64UjVHgCVFLCAwF0ClvkQC2A/T12xh9AwPzSs5lNHEj2aUh0P+PXMefq4S/86Q3ovbS2cguD83dcVjyVkqBnjwTNmQtXJYCicMPEXNpsCcdkrBNY50or8OPnaNBCJfYOaWofkQ5yjD1Pu9uACe2hryor0hx0IVKApTRW1/V+9It17XAWZ/siGJdw+6OPSslO+aCsrb5f/pT+m+6nv6srD0VGE9RARrKlwAxwMQdTX/jAYuNutamm+XB7ouqXP5+s9/aE4Cn4NnNL9WlS3/JZvbvkEoAngl2CGRb59l9VlxtnuDztZvjZV6P955VWYNL/1gdDDKXNrMs11AdjUhh2Oawfzj2dakuPr+WFOepgBdp1KhErVloDqMjAX1ACmE/xUkZiWpljYzwjnmcXmmiq9wZ44hmm+NHw0OFfgI6kpcVCZSfOA5cUFU6vDN8vfyQLzZNqMvV83C8qD/jD5A1m72D0MOOOpC0diPB8YnOy2NyWs3LefTjlgbOn78dI/rjVIAFQrDxBiKrGEfVzPHp53hq4c3aLgDOz3Ae9Q5D4Ek5kzziuvE1tIcl475NJng+PZRBWCGDRjdLQBb0scaM4mv3bZjOf1M5G8oYym8RGsQyFv7QjzU2KAWzjik4lOdvSnfSW59fa808WPvDVwrXH0OYhW5pre3nZc28kvWYu29HkCRhscrf4qvS0bRK/LImJ5MfNRyxVssToo7Q48cH6zItgD+ekFKDYiWjNELbyQTWnHdejJCz372YfK+i2yAUdtSZn8eD07U/7DI0pQewyWFG9xpsM7l2f8Jvjefa+Hm0xVzvDfKhJjp/ORHugq/3lB+MwVYe1VCRaTqYEsNaODg0R+C6QJPnCykt9lbOk5F3D1kO+RSE+U3gxPQtfO86xnW/oOEqw1aGJ1GPHlIbUbGrMvIXKnHnsbHQghbAK1vtyOPG5L54KQaALgk7HVFr3oZswxJAZhjVnvlGMWAApx+0mVam6YjzrjMr/GFgxGJ2A+j2TV/q4QLbPsGdM+K2mIvkcK8Ayi9CCDIL9ptoIzzxri7ZNA7e62fa5VwgG/S6S7t15ti2MOJxPtR4s0Dwd86h/8M7z+yuVVTVQ4qAiTGzAbQexGqyAsCxuMyLW9fdmmU+LseVoI9mSSyvUBTtxvr+TX7VlTgtD4OeTSQ5BwmpBTtuVJ2PiT07RPJaOvbmHdAv+q6ZmM9d/+y+3U+j6njSuum9epMr2mMwX5F0vO8TP5TyyZx7OPugaduad1Bn/y6RMjC5MGWmlY0b9mfQ1odYZbEFe+8m/Wi95vhthjOLdOoi0UEW4Tr17WhSji/n0Twm/VEnkTFPMInPq2GKQqLafSnE0AVb4SCFoi2FPKny/u9t2uekIMPGVmftrcKcrHe1H5469O0w41A5ijhpVJgqW/KpStFiFQsD/KU0/aTvhimSFJ/K6FxyQOeo2/Ko1/2Lb2PphhMpHV9tfOEWAfJGZrsldakUgQS6AB0hu9YejAlXprJZZOxeD+mFWDm3B2Phxmhjp+i/NGl+YIVX2UMPk2L1V7LJOrv+RX7oVz5N04cTB6k+Yh+pCiTmIK6YHyojxvs+HXk/hy/B78GzYMVxx3Lf1vPht25gH+0Gn3iv99Pd8erDRW795hIx3wxOJTEQzH8DX3eNz+w101t3/FcqrKrMEprW0Wjc4dXyxWklJEvU2wUk9jyNpMz+zDt1okr+CNfvuiyXAvNOCPAWy4j8GMfRQsC9sg8/vHSTKZZhGbidMsYTAys7wZ6oof7sAMNLxhtBiSdstXmqaZHJtAlIeF2/4zAeZG1Os/HWCxoPkLFiif2u7xwzk7+or0URp0wNss2Hv2J1jQqqwGZlkP8fjOPs+7HKrvzZQOaG6gKEpoP7qzp9C6tKJ/d6Ie66rasDsa5Xmz2+O4ulhjQ2/ZwXx6aGc+9zBIWBwSyztwCjwONRQV2GFANd3dkOU/VpK/ldLDsn7zlnxxh6yQx+5uA7iGLxa3ourcXQQ46BDL23+q2P/L27a+JAsggN9mCcfr+m4/P0e9ID/Vg12Pmh5wPFwP/DSEufbEqd/037kiZRi8jn9KdVsfMLAH+xYWPZGbLCmAPc71x4Pf8w7uwHxWy7pysiHdCwsE0igyEidmKPBamOu7cwiF6nJzKtseIVETQUElmg2Rzfkebfamars7h7MP1q2owzsC7yD7hqdApH4Y0n5dQZSfKnzpG5cWalRk7xr7ruHsWkrQeTPMcsK7I/Dx6KFPOiXeGE1gx32XlmgQyg/1Q2mjz8GodbPASQM4SSyLGAB4F/anvgq5Noo/MA1Y7VJSqeoc+f6fFZ19DfooiFS8uf/eD4p8HuMu1sNzp0pz+N/VJniIlgoHbKOtGulyRrq9UaleCDVudmePxAr/6EqlcDyv16lEsSv3hear2yNOtVRIcw3xO2QiuplrBKTPxyV4VHfTT7OFnUQAhSm/HNrrtfbfUKPfJ9QIwcLPT6qch6hF8yA0orglRABkWticJ83WI3f1gc55CKP6w+EYBLDBX01VeRZMdHodM5wkX0nAfhEcR2usc2d8Xo3zxbCPPW9gSUv2NBF5lbtLy0l/9HRf8BULzgrAMxF1acMPVgB9JMx4NQhu9m34hMBJrl1vsqYyv/f2y8EfsDb7hCnKVbxTnM5fw6TwD149W2Ric0KTECdlj/IPDrHBvessm/5jXlmOwIsoTM433g6Skp9doergmsMRmO/S3QdUrS3kyTn9PZFah+c13VcNOn4Z/5t+AVQvrzw+6JtbWC8TvYWmadoZgDFtNUJ2RlEH49rClRdLkqv3x5JVHg6x+NrYOI4zxqccgtWmqDghkfeA0cGUObYXsWc1MpQNcDzFZH68mAr9eENFZvu9yG0CAjhc0BwwdTruarIYpDYSmJIanvedYL7m1tTPkFzDFd/mYPIpJLtG0oHPaw6s3TfjU1u9mo+eetwgDqD6rf+KBevT+KVxPGPsADxT0cj5jU3BiuCa5gyHlNxdcwGAsUR4lBdS8xk29QpX58YptiB62jp0iyRVI3lgiSX/sVadBXyk3+e3t5X/3qVyiebwByXHiTPYkRh36kOpn/3s0nl6/yW0uJ4b7FrotNEjGsdVRXvJH+GCDrZLmwejjaEKc07P9XzTXkRZaApg8/959/6qfKcXDhcJaiRhJwnLvmI4oATfFUQB8P97VXoTLcH79hASRDs4Xw1dp/wFNlR68AAGpf9kONIM3TLWwXrTepVmtQ2jiwdi7x4xrygfAQdn6OtuJMAVrlvbkkwReDLdnlAU3no6qc0bse2bIU1ESd4MWcXgeZYl+dEOqY9F2wumqPeEx2EODM5QpNXGjd9N7Ff6qbzbcPWGNogIe7ZEO1JbSm8hSqY+LCuzGvHWiY8VvZ1X8NAbZ+u9LYK1Y+z32vFzNNA3Pztaotm9xkiPf+VE7gXgnWdDGLLwe9/YbG138MPz9qZO4MZ6HoT5NB701xMqoHyPl2XrqA183euln09FLKnZGWo/mNVn3uBvGwqmiyq1wmUBVuWQwXoh4zh3Osl5rKPt9/979ANEV+qFLzCjncgqsO75N2A02Bp3hwUNGXqqdwLfo/6Lf89u4VSO/5SaBY3UUC9jM8/N59Od3A8vJJ1Lkt/X1cf87e57hh6VMUyKyyM58CPoN1W4t28NJzV0gGtwwjRT1HJ+yavvjEiVB/l8D8kSJuOQnqL5LTcAI7Jqe2hYuDwYVGWA1y8SamaPhtCa9sLgzC+znyXSWyui1+K4b++is0AKQsIbRXxeALxdl21q3oJHniqyUwe20Zm/d0IrtytDjb0fH+X2Ry+ZVUjsV+Oqcrv5bP10NYM6CRBHWn61GZgyFz50tiqty/zVbcMin2dc5rOvYEhJsNlg6eNQn85IAodfZzBE9atrT7f/G92r3uzlF9+oNUAp9vvv2MXdWp4t2YhvNZUA0a4DkQveeoCFkPwSLPGGmYU3ANFCD6++olNKgRUUCc63T+O4w4ain2qoSi3e1gp+f49+NQAep5WyL6N7f+NHOHb4pQ3BpjxsenczsHcv83PzPS17vVW4S3FhVNtTQwZzsIlzNfyfdcC/89aokrfHwRw/AhZO+YhyqVmXeqIqS/dLbr198lUOe4bfCCpZmdmT3tbMC1aDKIWR8Kqqwfutq0IWHaEBcLwZKfKG/O43r69/SsUrZ1FVrMkrby82MiNF1JsPFQRff62MED7jFnlmj2ufc0jNkeD/v7/afNVrjVYekZHMmC/NuxFQni+Msa9Pae0vvbAEkh3l3mKqY0qhNpI8Xe2SYAOm126aK6BBXXfdviZG34+tUE8DBJ6gPpzyl7pAZt6Lwk5ciG+ivBk10tvh1xIzHP33AnA0TOQI37e6UFM1frF4A45eCShYoTf+UgpYDY9+P4vtQ1el48KhWObSELl6s1iSAczZ3sov/m4hpvrUdxdOKuqPHJj+fd93Rvo6Y9AGoy6q83VY/ksG68+7B2YFjkHeb4OkIFQaj2zIJnS65sjHdox87dp2f0nXpUs4nX7D9Lr3TbK6HA4axQUXy+XhE6LWHr3RMbNPZ6xvApcPL/rqkaSeBpZGl9MPjfuwEfqtuFOa4maYV0FZmTZ87wTy1k2obzW7PDzlZ2JAKl1mXh6kdsoG7AO/UdSJtfp4SPxzvFHajViDpkK8Fns6UfzZXYlLsfhzjh8S4gPR+ZhD/Ra9q2qilOe6IUo7VqfonVOL78dP7iFJdUe86g+PP+qSXtqP4uwjjGYNvbMDXzi9A7sLYUy8rgct2Cp5aR5mjJDlitrtFP7sWVW4i93fC9L4Q/np+bUYWJITTgdNYSTDEaYgJrMXncpS0lT5YHtNcNaoaSxL0ifhmFSXPlO1grc8ff9/0v5jy3VsWRYFv6b60KJJgNCS0GAPWhJaf31hMmJlrjzv3npnjIrOHjsyFglM4W7mwlxnxSdAUU+EWAKVkXvCg7soghWRhd95vrWtTWiyOE3IeXMUxPxEur/NoLbjPs8fEWjYWS7vO6rw+qq7E4eyO8OMXLnsZv1tCJJ7C1UFPZRMkUjxA8pxXjeu67KDApNb2jnsxqoyGJkEmoyoFHWGDJ1h88zzRfhQMYe4VPwmjff74HJsgK1ieC2B9PjfaCBKA3eTlWUD5sYfVgofHJtWMJqE8msYQBphDlgU1PCaz/J1LhF7jaO6EoxncOtsWkQ00TfYV/qx7UP3IxI3bUQr5/QWaqfTHgu1RDWWjG2AWge/x7v4VetnTHSDSd9S+iDGKSPpkSlgoEsuDq/Kb/gJyj3TbHATVuxBH3W/XXCcODWYtXFbpaBIpx35rJuVJh6omIdvU/1enOmoOuSFW62FWpKwEJAIe99hetpFrIuc9i4qQESwvBT3giHlvW3pBNFQ0B7rbAEQHRJD1Jg3onNNN8oA/jAs+WHIk9Gc0825pWTLPG9AeqtuBilfNQ4YqVqJUMsS/upARYb+sS8CQhai8q/C3/2fK54bqRmM6AE3ppKS2Cbg1ORV6TW6PHTdVPJqyeQ9StftRWurT3Chx7ZZ9fdWfjv4EdIIkX4rCYE6VfTwFvGg7tfBbE8b7VfzY7wKZt+sA1dkZwRBwGj6cB+CyHPHss4Yg1nbu+3L9dXvRJefaVR1CaTloMSxVzJZLT1h2IEZI1JuJYqbiNHssPXNwpcz8MLbLrUNOXM+0mV9jvYbA1vhjYH/L5mt/zGV4kF8s3RqhMBbA6o0TdGUxDEI5sMxZCu7lzW/FowyGGZU1km7+DTQmXdtKkobZlSbDE/QBVlS6h9NGrX1USb/1RPEdYum2Vl8cTCXDkekDkl1pKpCT2BGIu8BgyLqVmTzUcmb/lkY2DdDYY/FzRncG8HADmucBKebBS3Tx+qRIudifoHXbDz+jU1XSyyevoLTrxr+N87IFnLRFUnsUOsEuX3DynLu256F7RANMGICwqPDo3yhSrXuLPwzRZRfyft8FQ1NyjiZjrDC15kOg6SlynbQ7bSwmQdDpXXRV2EifT9CXSBUvkLO257cC3BimvydxeYMP344+vrPLd++o5XhxFlWoCs7o/ceun7RoSQROaeSOYJ38r4/CVmk8yRyIx4UBMzVBRp9lfXg69Eux42wD6ifQOPg/wsi/qIIBYx2cP2H/GlYX7ajH+VuGWS6XjDCCVYKYNMkqOrp9qoHml54O2gqM8NhmCZjyAB6IZuInf6afGvXleLzVappu0x2/k8MXg9RAppePOaXwypL039PXMKyXK72VnZfy78Vj6kbBNX4MwSeQI0Xf+nQ5qEuXjWlAD+8tSq4nf+MnpLRm9tIwDSDCV3XkvqJz7snG8aYWU/YAAqBtwvtO2TLPyLjPlrtNu5a/sGDDVQoqOF6byEoC/lR0kDIk8nKAwe26ZpnEMKbs1YtkPgma0SueJAtW6AmhQFzoZvbNuLJ6LU9wCbgjPOjS6nWsahSQTSYTMAbCL5buP4ypP9D59b/U3dbYP3Xwn5OSGv32d7ld33wXOFC5jdFJG+gg5jQCRQLGtnKX9msi5oVxTiyhoyb+LbWuNMUZL36eFVQNT9hCCuFz/HUQW0K/Kqm6ibkFrJWH6Rp/q7BtIubt0kwYmqI8G/GnKXAtHY1+sl6uAKsyNa1wNEa5BFwPrdBktAmMoWnyE6fbwXWs5sR9Ya/4+0p371D00prH+F64f3JOjuYwfHiiAzfW94ENVvM5zthnjKEZ6ydNPuvgj41/+iJkPp9626jiA/zh1wsuHJX57bhaxAvUda1byCwCNADftDysm19K7L+h+sxpL0h3PLB/ZR+FqNVKiQ/3zTTjRTgmvjzPxmI/5tSc/o7md3sowYy/T9TU0B2IAMHvFKNY/drddznTlXOaaxSbma+VXDnlq4jCYxKmHlts/TkjxaF3wA978rZ2/spTyj0Lt8f+JHrdxBojhXk6EYD/w9PEhrQSRm/m5f230oRiQU9JvVUv5m3pfBqOUBnTLrKasIdzwxV32sRCDxh8xTjgDVRBs8e+Rxo6mtV4Oz2syyKbpv5WtYpeELAjr0snP3pgqCD4rjdR+96sUFlpljsdg7qctmbUJsYWU38CJ/jjT7VZ6FkYq98Vc/CJUNimsoOL40qJ/esyTNh1Ek+jwJS3FJvqFhtt6dPgmgYaZ+chBHc/3Fa+f/cD/ZV/kwegFJbNjJLlLL+4sq32FqRukSVZr29fSYiVY5KQ5OLJuCG716QgfjboUX/yXrmAxtsvehJ5Q5Q7v9JwcJ4Lu4DM5z/1n3GQNkCqeNEOFfY02SrBYOvGLjPI+Coy6O53v3LU+CI+7EpN6m5rqfm35jzWY7lV6FimRi5OUM+sztPf7Vqm5PR4s/+RAas67tntln9iZrv+ROUYOLlvSemF5KjbIofUN9ExiCQt8kUkdrTDKZwrEJugk82K/c7pujTdSdI5iNTCRP0zWJBPH+y94XGCfD7MbUKJ+JWoa8gy6NRoAmda58r2RZhC655EAym19UX9v+SAfrpJGBCYbZCbhgb29Uq4/YrlcxCSVvrFmA28Aj7pa/J2xYz0Q2WVFNe5Z/ZEuMzZJ8WqPuPYAkTEnKbsgbLmHNZ2q8miRmCgezA+qDKf3TxcVcC9winHw78d+5kKBqQOwGlXcCxtTf3Aie/pFZHlj+2BkxoMTWRy30CgUyy21/fLNa5LTnr9O0hOhMy+VOWqxDI2sMkvR7DPtOYr/848NyG8hKoPwI/buBu69KbaZpQyn4LRJxzEG88G/8SWfQ13tesE/seBVXd4BMJrab4czD6CuHoCwaFPy6kPy0gF815efXlKV/xARm+kHeM99DZ5CygAiKUbOBqfp7nBKyZAayZm/9vbZn1tWXqFDVTv3ynFKwq6+aU4a5IpmcmTVSyHKMLBXjVwt7gw6JYbZUk5MKO4outFkvof6ZBGc+4EwmhjFtPZyEj5uawEhUEacCDUnQFhsHiTtL/PR1LbV/Az6gdxJj+4y8/82Hjqp/i5kWzAtS2iRVXxIbPjvkqX32MTzc7oYD+KRrE366rQKlhUFzhyq/OJXKzhDKzu8JIv/xAtjuIQ2K9D37F+0FvhCn73glCAUjsbSGoFQGqw0RrBs3O2afdb0dMA8muM2k6I77RPG/j2SZUSWqc1H0+eu7fOVFV++5uV91G76SR/uu9S1v0lkUTSkgSH9Y/llriuFXKhjqYQ9rRBOCdxZdIi6hVfiayCyXIQdT2+hbURrMv89ghTIQw+SHktES+FNOrlWAfx4Vv2wPQixDovXMBXoMp1mzCRtLc72p6Tw3U8gym0tz78W213AM+8lb4xlqmalA2LSZM8Tf7KRkM7AvBaL7IcX/hEQ6RMGZfNPTFn3vo+gyjC2B7scwwTfItP/Y5UIvTFGsEGJUKj5f5YvGbufikQ2hQpJSDov3zPUPjUNIHeH37HR730ibbaW6ydig4osoNOgrhwWdPUGQxQRnzp76SeciEePIlYyYHHHxyaCnpdsXLVaKGvaOquWce7F73QvqV0Dpv9mZNRfHSxKJ8n6BWJqENazwtwccwq33fDq8BHlxI3WrYHNQwv1xUJba2jnwCdJ6xiPDTJzcOzU8/DO1S26TPPuinAbF+cBK0SJ5ImbPg5j/ZW/IBLBBKqqPyH23cB4iZg8MbnDeGOAnahJa3hLbbF+Fy6CfL8/hNqGV3ox1GbFn1HZ5WOHCN+IfDcizCPG+W9ySZxjAjDkYBHoZ3J3q33CcDtuHljs/frIrP2UXD9qB8Zer4BHTWJXClpxENXJbsMUzP3qfwmX9Hy+VXHu0dIyNNZqIkhSu2Us9DWSuFIXyVc7wOzsdwNsbnfUrnZXf93XvaQvRntrVq24+EccrocaPqubFBPkHumZ39e/a1Mko/ukCD9in+zQ396A+VUd4hoqKCAnpmfIGg+YVDZLh84PjztAZ3KKgsAKidr13XB5Oe6+HZ0K92/4f9ZdajxyX+20Cuz0uiPzjooB8jihjz7WbFjhIJ1/+DKICuZW2AwFWYTzQ0QTp9sXCy0w7B4z62G0/Am+wTBHFTIKvCp+fGg+gmF4JSPiXLsujdc/fd2KlMVG6b8R4Avkz9yaO3rcqk30Fk1xWm99XORKuHUqtT8HTF4i/Vz0TnInH5eaGoqJd7/5J5cGtBv7wlMlBiBCq3l8kaMJUh1lCUbbc1AjW6TMd/Nc+N2p/eM12dn3z2TbGcRmJdawCsA1C6e5tBAtLgICh2SSwPqXGg3DvNs+q0SCF4vodA101iIp2VoletXPG1jqOZU5SDBlt6jlf+y6NZRq/e3zbVl4TTmqwIM4y2GQgslgG9bNK6SagY4Y1foyzkPFchav5jTyqAnvzClt6PvzWhbtYPPZyn4xwD+fnUhE/BArQugXNwL/+DwY46W0W9pBe5MNAf5WjrJar2oBEHUt0EHebvPe4/3dlr/9g2+VUXcVm4Kv3G7RpSuPSGbH2HxibHOO6SRXQy+UfP75ZiuaBY7sv73r2ncEBbXVQf38mUZGK4Z6xXKEQuaHxVLpe+tdAzEDsSHYjQhfIrKp+lFcGM7yWHyXtv0XcJuCGIgvn1CqrJYnMHc03L7EYj9HaA1IX6Bp2vk3y1gfF58vVr+hM6OQD48BvrlG+iC0+Z0hisS3s81oq8iV7YzGftZOatwb1dmUTJSqfFSuSp5OQ/Po3Ho3E1Br5gYhDMtH1NL/fZ/mOfXgWoHbKNb+0QIrwJ3w3Ye8OsLb/2QgcqTfWNQru67v6q5JMf+jePFLAf/u88kv7owe/rTB4abJUey8xlkOudZTWMBlHNf3ygoXFYqWF6JuyqnXJwVwYtf78Krjuch7hBnr6kPzUxGufXgXkwKu09H013k/x4ZVjJ8oTkKc5Se58fEMz+nG5vf1VG9FAoLPlYRRSFmqCDWj23QBceYoKpaes3N/TDKxHCCGVko0mgLJ9+hd6faY/EcotlN0I/Yn+Ek81GtwGqXNIjK64DRavq8wL6gzd4uREhcoGGuEEMW9A/ZA5J4B6ouL1rRSA6Fhc/EDdeCagVaqE3Ny7U/TbcVwvaWS3jo7yTAVDW6DvIvEXdA/6qmmQTS8LrQcia+UffuxAf7o1yPXA6ufnTJveWCcW2J//ryrr9+c0qTymUu7V3XiuoorjZDmNFeRQH7clUw1LeSOi3HtjVIO7rSJfmYN8pX+tmilLLp6ui9oCKCIgBMq9Dfrx+rPpguy/6vGQStgb/5nzkNJpUsCS1pX2HR4ihCJLdV0IHcMSq3gidJi7YTEXcN4xNzAEiC2cE+Rtk8766bZYi2dUH6BaAW1UCwHMcVu47JpImrtAv6MZXpeu/KYG93wICKigGTl1vjEhlzK+p8iJQ0TRvT/ELw7uu+/ZiwQzWmu2EKrcdRSL3AFpMzbTbw1ArFagfmsIwP2IExfXvVHhUiHQzhIkIcvybRhHSElvNeB/W/plStIKInAsl31V4gzq6m1pCjhQ3gxeKKfLN+yGFqdsChc5TAmmY33fP+hr+narHMxWoWLH3M/yWrPy7n9Fjm14jgAvd5fj0YmjPqDOGIVxN5flIBzwW1m5D460+DlCo8AR9MT4+8B8irWvVwnn4r1yL0LMruNbIkyYG7hCOd1H24W3kn/vtZVNz0b8RROZxWM8dPev0Qh4vm1EkFlVs+KrEx8NWLIK1LUnMoq5hbrO1LBCMETF1Aw/6W4fQdvwn8Ce+sxKc+bZ/090llyVlvRCQXbzoGPmxp3NwkmkGZj8rubKQ39whveKoWBJmMIFOavgn5sST9DfquTcgAHGlEVhUfzIMb22dUjh0ix9MItVqTLA7dC7aFw1C7docrFjdmWDW000veIp9xuCbWTv5+VjiC5TUbNQabxXC+zEfDoSi8UeQgy0TSPVTcUnsbkT73zysYoP6BqHDm3r+Gz9LFhjvyp9KpIjS2vDkSsESxCiZVPbzo9rn04Fj/DcHfT0PQ+XBlAU9mcK9OJqypRkD/me6m1Q95Moc0alpK9J7zGYvJzwN7kEAa+z5PSeu0ovY8ERkpX7cpvlz+7zIZo3kpTzA1BKXr5r38wWZ4Y0tg8w0dR2kVy98uqjwOyUH00BXCOA0QM8hi3VYxmUJ3oAyoCZXioY6PaTkjx+ZC1SX802srdtTgrfYbBmbbb4/lLfSfy8YCv5qHYnnbW638v/zl3bRhYNyXA+a1ytSr5/ssbHXEj1mr4NrsjGdqtAzEwCuNtjO09p2Mrqhn7iwwR/JMgzICMPfyWnS6+9pg5Rk4fOrZ6VETd490MDGKAzc40JZupowgkXCJA63/nfKZAvT/D7yM0IfUy+dt0/M3rZ9ZPmZmYOhefV9PCFFJCPiKbKQGx8Jw/3LsG3JobSP/lWHEO5NaVygGCKbC1b+6d4c85dGNNuvKOi47QIUKhXQP+MZaBBcqVAxXsmFG1z6MkfoSmtjBHq1N5ocOiwGFs8Xza6GkRtRvHp5R1T4c8qqNcEnOaMTGu+bS3UuhRFrByYpATYTHfFykop8YonnP+/fQsvV/lZ0WAH/AWPYe08+9JHO0OQ24OtUEWS+osmWGjf6sQHftCdGEVlTx6MeIZblnZDxp234f6Y6vkRNlpgr/VCTo8y3AeqJGenXnELxEp5ou462VymwuBrhqi2SNQMkKV/Rh+IgRPqXHzB9DQI+mjMk/+WojQX0Sbms/PC8dnvBG9mgFP1kgIzkbftmz6cZDI4Gy1pFks4WVrlPOE3j56LV+I5pzA5qWyv9eS/l8gRKHaqOFnuoL4dP6+RQHCH0Br7NZEoeRK1E8k8BZO0cQB2u1bt6xyNeEX06W4PE/BGIfco4TYWrp3fP4kiN+rXbEauYpNTYGh01wTIivuYxFqQU8z+9To+Qjz7q9b5tQ1NZfFFrVpH80YL9eLwk5LZ4r+K8jnEyNlpIWEnWy4T3XBm7Oz6g02WKkWeL5SLAP4yNfhAFjvV9JjOTLVh3aplU4XtQ6fzagFrYlaCQSok5ldfo+4AHyQXnFE09NHutwRtmoUGU3dDLxab1nA3O/fTlN43LuzjorKi0iabmeQPwX23Pb2ysXKQ+tr1Qvh/PXplorMdxg8dTdxPodsHfPBkS738mvrDF+ht/VQ1CHnUyS+h9lixkQSWdOf+1s/bYFE8WiqbZK3v+P1ru7s0fOowegGdEP4NdOJY2KaDXf93fi6s5WhZrH0tf0ekYuEt19b59WhiVxZlsBQ791ex5wPcGbhtG2t+yku+8XEHXaUzqnAGev9HZN2+ojr2aqlU+aJ1emAyUY4G69Pt/KoI/cDBbjwHyJNazLPZdI+BmurkkLYzv8HClbruJ+D9TkIyH/d70PvXj5A3iop+N1sefqIj3+LPb9lqdl7FJgtYkxivwFQnYgggDD0l/c8v+1JKICJMLjoL2FqaDsdG7t0M6xm+rtI6ZtzmnxcQC+Rkw14/4Jrg0MRg9bQlYuBau0C+AxvwssLKjuo/hnZVbjFnilzOb3IhEulB1xqr0kDsqlDsm9/uwy1LBus5DG3cgfCwefmFm7r8W9dfD5m7XtvacUvZsPtUqyR35Y3C9iQk/ew283lMq/5OvdBXQxXYEUeK94n/ZojhKPbMv5kXM8MAVNpa0TgVtE5m9QNQj1DTuE7JHutYvNoDT4I2Ip8kOF1Ci6XdQ6szuIPZnyTiLxyKEqHzdju/vmC5jarthE2aAJ36q9AhYAqUG2Aamts9lPHohCpa0ro5wpv3kzz2+wP4DTp+tMR65y0F+xzI9Fkx4qNQr2IYi2CtNepxS8qhuBMAqVakwmC2VHOQ8otdIjtgZWPZzN4zsPV7iU2jEgmooS3WFlx2jXHac6/ujtbM2DPK73C8twZDv+Fhky4PnQh0b6eHTBC1oGniol/s3Q0qjkVwJFaSZRzRHp4D20WqX/8GaUSkX1Oethw9Dc0rqHSq68/6pe5I5pooXCVSg8j5BRiEt82v2NuPk8fnCqb24/7nP4T/TXfnyHzKlZYS1XUUBjEJy9jOYIcmQ7xnJU+r9IHUtKpKXRzvFV0eEx4k5MDunTf3t9biGN3OK0EeeEfotWztNL2V9wKCGAmZKs3PhJRCzi1p7frF6x0Ux7o/1LJJneXsTkaboPAPlqo+NVietAUB4V9nXItI0fXmj4FWP5r+Zpc8MasHXOXvEfzMoQoIet0/JIKDToBlMavWPVnweN71XpqksoBh9POGbaKPGlViWrDMLA/8sg8mvb4/hdUPsQV8iiA7mw3llv0lTGJ16+1tm0xkpfaCHjYgAyqE3jwAORo3zBOvek3ZblLYwAYPQt7+0liA2ZGRQk4zcsMhcjKbOYmey8/7e2Ufx4Fru5b3qJfyJZ0mYcNjBfc9QfcbmN9z3FGQ1BZgpwxVCJthDIYhfgVquyy+bLAbML/KbTsbeK2R/O6sg0D9NcGqpaqrVbkNmzR+goWklN+TD/LXEXv/NNoCBAI9ETEzmuf8nMmACew/sEpu3xKzj3AR9TLm4VwTQlutSqjHrVoyorc09dqeuSQQRjO8oca9FyPdC3sCFJAlyNa/zpGF0iwVKlHGSrD9gSc8FFNSHUzzF+2/k5CZ4hOayXH4/zuP5V+zJZx0GnyEf+9iWLlKfRooa4YvbXrtcc2j0uqoofN0g6pDp4dk+3Z558C+J0mC71I0do2UBQwBsAjVkumUEr3ji4elo/6kiguxfvQCerH130HKK3UuxpNUZvi3kPzgx5F6vZ0HcD/D5/K37/hUbLSnSN56n2sgWFBCEYpjpMHMdrSGHc/Ojh2aaz+f9F4DFQN/yccV03m+cWqaJJKItpxs3yYRwuYDe07rBUeNZzBP8/QWU8xjtcDnM6KOn+Fdv3cLVD3ntZHkQUyd8PuSHSJzsy8p/T+KL3bkDGAnr1AfvUylFSsvK9LlP4RsYY/YtZabw9F6AvDVF0bDQtykFqeeYWovdrvMIhpCPS5+v+cs0u936zizcJ3q57+okEIuAlJmqhhti7AkYZqrR2aeJM1Ani/Glog86M7blf/LErgz6mVlp6P+TMecgUFkSD8YapDPJQp5umqkvG+/Mb5v3joLmpUxpGggbr3qaVDU3788HZ4meAqsTodsVh3bnHKCajQ/yJAlz0pyaCrh8sIj09UFzKjPEDE/uf1mzZsP8lSUWGeE4pfv23DSxaxn4XitR6MefRiOmeRexLmX7IjBLQMDEIotjx1V8YUfcyr57uwMnGSQLu5py8qOUD6fHu8VrR9xOc09PK9iJJ0v/URMPK3tPVrYMl/zTH6yhlk9eKk99znPBi0JgFKHbFS5iKyPULLNF9z86KWeAqtY5YuO/uy2VBwpiE78mESQ7kzK6T99WFQLxe9u2uq7oZV0pSH59a+4APbdB9pPZ/CkkZNZN4FGFv3HuuJqTJFNOgiSfUvCmjY9jk4YPojy0Z93W4CnU1G1hV5rywOVG15SGt8FPy0/HQKCWR12w+6QfVmQGFs9pa25bPD283v/m2ZJHc+UTxAyfwJwkTV9T/3uCGen2GeHNl7n+bCA/S9tBoqF62DUGGh9vLGL07tSjRRej+Tkq0ch1+kiwjNJL5bQUtYX/IlilVEzPa3ne0EC27jwlFpxPgi8lv+YGaSNp4hzVPvlPfWGvVYXIQK4G6++/pmBWsgu8DuZXYeBGjM/KPpZcKlgs7Sty2Zu+MFUImY3TdODfXRB7xM9y0xnXzBinmLyOo6qzhKJoD6oe0afr3d3cQN+FMAfWVszbJj4hH/7dHYN97rM9Dvhxnkt1eCLZQE1qYy6ES6/fp50w7uYmg41gGE7zYrmYPvwbH4oKR5BXqBQ55CsB1g8qijQcdTSPXmDfe0jstsTe+IEvTRAYUGuxA7uOoAMTG2d/0p7uE5Qr/CBH6fP4qe3jgtwcpB7BW/phyIOLwHz4dzZ7lop7pe5PfRbG3xUIRgUKlF7P8+lfs8SnlYVfYnoAK50HQYVDZHqEZLrufdChMOzwrFweNNY3XVXnea6bPNC5OwYshVH0RPx2CBXNAVWOLKTwJmo7FwFUZvhqnhTYMA8Sv1gRaug6KnLmH98yMnJF+WFgz4b5sjAAhfnllX0tp/av5VyjllCO5fYuOfR+7vuj3q+ncjFmNKhAHE14wBs36GNUX2TE0qGxevV7GQ+iuc0vaBVtH+ONbcpAdWg85M11Yu3qNiyUZ8HmCOeImyagRA8w6A4KC++/3tgG3lgNudb/r6awCDJyxmQTlT/fK9moAMNklgomc1JuTE1CSeNcuVww/HOqWWZ51PdtQt9prJCsxYYy98g/tsPGv99YScEDUV50qRiHzi0uHrGmddNCnn3X4K3f+okV0SCIe8Pd3L2ZJDTK+c/MKHo6CVMkVZFdFt+sw6dIdBIMTeY0rLrP3RWh6ZuowXq9ZpWFsny7IWkv+xL7n5i49ojA+0bnhTF/ZyDFnwgDmHX0urTjdrHMjXa0c9Wcfqef2PmYueDfvOSLiwWpuG+pJBCTiC28mPWWCf+eS03Of99Tv/wXCksi3IwsbG7x/Nmf+hOmHOcEc9mZpoliO63f8Gcs7BbgZBmDUs7+U+MFYlJJwH4Sq+7iRD5MLZ7BXjbqzYHuDyr+R32HBjSisyJ8dvhDemKs8Hm+btD6gzDiHfhFfUIRUS0mCvrpJGI8/X5dAuW/pUHf+PZWnw/c9mnnvVbTDpmL5OGX/I82kc66D1lDP4cKnAB6EbaJL84D+jrhgRt+/RgYkSp6yW09/dlTsfIRchn7DkObVS9Ngrj0U0jtvMBhHPs67wrfDFOe2H8yUsz+05lLL88lmZfzAtmRR/K0UXwE9Wv/+qpC68DUq5BHH8qUqzuHAwzhh8sMcy9UdgkI4Rp8QyLSQCUWRASvxXt+sJfK/OasbDl7oK/7tjRPlF0gkTU4WboN9IVaWTUpKiynLUID0LoTr8UzMhRxD+VE9ha3uPN+FxaqXsu/Ua+Zi00lix/dmyleksMoRQ3tNuYkPw+tWezriekUlhcP7Gy9TBoZFGqmLSp0s7ExuSlRMtQ9xyRstrLJ6F1j17DginqoaQtsbGX9ogoGZfjIJNnbxgTX+7XyzQyIhNgbNzj9lGjuqF6nxV/MeRtQ4Sz+jl3bH9BDZJSuapziDT85nZ9LOX7gtRHCXeVy3k3B6qQ9JghNF2Sk5bqKeshskCh5cFw/1sle/MkEGo94XeETOcgbC5jHk+XIJxPub3wntQWBrNan3vOnBp7xymOQXoYPJ8O+vGPCt4aV/u11f4EDdj6jFh+gJj+jhh4fP9aue2wq+XlTnAgGc1jrqm62lbCHzL7UizWfmiZRxgHZD3OQBZNkkKgGYSFdWZEUiPv7vuycUvO7U+bMeQVGecZ1Wxr7WCdrgTT1ZSVqaPZ+fSNCMt7E7HfUpE4+TrWeKUoLuaeSBjrJ1HifWjm/+YMP+lY2clmzF3DJRfX3hE0FiLqV4vq9b3Gh1RKX7eJYK6+Wo32tlUV+SqKglRlBcFjZNvNx1gzFFeHt06v3EjfMDip4Dt36VLvKLFK7cHRP+06dxu9U6BBHIkCJwVtrE9C31qLO2m8WjJryC+ncZx69Vi37l/mDM8QVBuXMj6VR7nugcSNrOoxa6vsf5vHVoCnGK0QPiidFt0AVyMJAez1U4rHbvx+f19N8Cm6Uy7RQ3FaydkcAhK/XOohCt/QqUWzqq9xQn/8EdgmykCojgM6f5e25r/tMBug0DG/BDOcbN/urPhvP2LrIs8kUlj2JUFg3MNEQiXwyWoRupFILvcaNnNRpQSeENMR6x5TrOWVQTp3UbRhPrxEPHDhbFDcJIAL+n5q31wMo1AJa1QRSjT2SPZAgBAFNccabjLPPVsqc2MaICwiw0msfK3h75dxc1Nf33H4O5DotjXyCkwxvY70uyU6oVWZjBV09lNjFT8iidkQBRTaKNQi0tCzyh6ynjXkyMavSNVKB+ghSB8Z8DbZ3RYOwEugdrmrM6uVU+YuF0Q+9F1PqtoXLTJGHdfXS+UGEj/+tytYeO0u1Qih6phdcnKYtjs3qjFg/7JwTquChURqZq7oUBIT9HJfxyQ2xDwn0K9NvyJv0TnxJH+i7Rq8zA8iJxLJa12mKolo+AXNw23jLTFNsfnA5nsqcoY6CixKFOUfnse3qzawCobe2vxmX9Pj6ryvi7JF7UIAj8eSI2dCHIZ/K8ErINumUo7CfmdnrcvjtGi4e1bsfoeUJo/5KBP7ggeym+ogGqg3fwbdLMTCjdxfZgplY1/NrSWgEohZgSUC5ZTZrN3t6Dzfc/NNBwD7Wx3R7PpDupGtKRFahfH0+DUfch35UH+ZvLq+Sno/7rFHqeAY3X4yONgRXhWtX1ZXxhM2fWiNsajnOQW6/kpN8O1rDd+BzP71zFqAxL3IgO/z1uUEo2U9AEHmcuKrFb2PXPzZ8lJK+Pl7jjq2gAriRKleARRGFISnlu9ZiIuOvaSzUdpnR83nhoaBkKmuao1l9E7cvnK6Fbw2qlbzqGR/F7f1vHhmoqoGqMw45EbuE4serEapqI5nPBcGJmSkfV4/fyFD/LBJSASE8S187fJD2cT4IiHoIJFubSlc5D08l38XbSeLRO9Y8zk4Hj2KzluVSp/75PuPR9V9hCZS+MAEcFqN5jZ3CGEX9tgbqD1L8PCrNSYenwQwOBFkOvw2Szt18wpE6gJDwAkp6cYKa+7Ya0MQeXQwhrr9nZCB8Fsv8BXUF9FCYHZ2t2sZelxN6dU3CMH+/guEryo3Rb4uh4e+fqBtv+MTRXcjNlNSZwoib5H8+gqFAEEZOcX3/rAlk2rfVuVE0nJCxFljVSRCjWp9nNauVp7V2DYRTDIWHaSrJzuSPNvly1cMg6wl6e0QdRIyRBQjH6Uf6gSM4P+aQpi/Nxcnp5sYVTWGvjYcW1fbl66yhn6wbs5i/kwUCGvdMc+p7KpBlnE5GDVOMjlyWNPEX+jgrasmz92/ieO0maIRt+rhZTfkKgq4qw1MWhAmMmifJ4EmMoLpZ8kDexS/Jf04VCG9jJL19ZZVJDKOo/UKD7j6FNNVbXgTmIXtBquWn7aLxAI/YWlNH0I3jugoDELSr6rqr6GY2gF+lZc9n6IjUrSNQV7TDkeH84OVffLfQGsB3Cwk22kSzGlZ/Xy1Eq0brPEEx98A8Pw2dMcM7pN+k3Csu1ctfHPyja6sTqndN98kgYFLOAwkJ6cbFKt1o8JX7KJ/DReEGgiCneY8P+BEjhG2A0TK7J/7zDDVnQSoak4zN9ZcAO8+j7YEZ56Ajcv9M1TiKDzidavQ4xo2JqWjQHnl034zxpReOe4MMY56zZ5w+VKjJ2iRcjilOfS/hX1qr5MKHV2LvsAAIhbnPj/YOyIMeqWLKk1JeNGoAN4lT7/c3XiKaR2i/kUmzXqqVDCwGJe/K3Lb3dYRd19UVYSYVFlVpSgO5JLfrRJY1gNEBObwaB7TnbEDaHsudAfnhuTyeJknfkWoutuc8KhWRnYqFyN/pOb9RarUi1duuzx6OYRBqfT7eRlQ3Elj9Hb65bxYYCInTK70uJnukrLe3+hTkv13XJAojidIBuHn+hL0ZbB4/VAsTRBTw38I5zLKz3fOG70SNfpquqhxRHCB5trMIdNs2L59RtbuNSU4L67eXjAm67ucbqOE+6VKLpR6+JAkFwRImLmB+RxCREWp8AXnvc2Vk8FORcP/tm+IOgLgO9Lk/dtVjtWqIEInNswEdG77VSUHs9NcAn4Gnj88fYM69vrFWQAIZCzN3pEmQIq+2LkGbK8kCaqY1zAAEwhi77Z28iej1jHR+ZN1/4rjOg0s5Cs2gAE3uh5CL7mWdkUwx8w8CEaTSNUOaeM7MY9Bu/2M4llnowFNQ2vV6Llnijr456H6UxxfmiTAXQg1eR0YLZ6PLF9LrlEHHKu+XkR7xnlVZWDOrbgWNIhdUFrR0omkYPpnHIGBG+mSqjj/XHd7yZFvH2ye3E++8Mm84POPzm9CO47gNbgN3NRJCwI5lyMqA0U9okcYPw0KgM4e+aBgEuKZp2ozBMafS4uFS0UzTfD4UHY1jsOfpkG+tCr77fWM2+1sH9Cc5xG9rp2R53o1u3bcuOElX/a0FQ8aUbGf7iN+5z/MwFh6xQKDo/VU+kV++u1JXh6Kilh40L7MkHn6+MgW45YkgDhAJhfHX5JzdBjq7e/4meZtgX5zTJNUbpCtWZm46In9Dk0wGYykbxHSERbDf9NQoONy0bxxNr8kDuw0Q6h1eOGwLeh1WaWFMfxrh0t5UVs2S7V1OQK6SF2VaBm/tWHRMsDr1T4/FbPEgtzarlpxnWVFTsSvL4Y/OV/GivtmFfU+lBS9ZgJ2Z10hK+oN9vLG337OR/76tS/yKPME4zftmOP465KN6yBJ0aBHptmzcfd+YL+pZlUkCUsu3+jMY7FnrfHuk+orEGZmlQLTkyLftPhbAJ38HBfYaWHztAR9gtumPSahsgx3IA6SCeP1+H5zfaW2/LtJyUCP1PBhtr+7nb5ksFRX8GIcEPn0ez9KOawTef62mj90/k4VyU7KRt6mm1meJUYYa53n78upxyPD8+WeGKLFxFEk6VhDQ3uGmKG/lx5dtQbBHpLf/QtBGLR0QqiD38qISGdSA5fJtQ4/r1+aEuGqqQ3fhpEWvtuOQE+2PvIfl13WBV/Xwn/j8V6Pj861cGgWQhlEw0r+/lPrbbnBPF/gaSwfBbL7CPg9RrZF3iZOadK8/Kypu97YB5DxLdP9VxjQqAUTLbhARnJE7i7pHa1ESWItThqL4lSkFuDDUnTEe6NaHOnAUN1V4qLSFlf9kgV7iLgIBagbsUhctpnyRY/T2pZvL+hnMR98nbbRKe76H5zfu28jAHCUtOki4Z3EWx9aver/0h4SejPGtiGjgZEfw1odhYcjjSGC/ofh3exiKYrT3B8pPeWJOTOErkKiJ6i9ODTW/rDD7qEOr7kgKO6i8jsXQlxYaTPuCApD3bWGcOBPf4kTptHkWt58lcdp+UMsoQkfg1qORj/7R08HAbEQ0zsEYTCZ5MopqtxbYHFBdAyVTTocyhNk18HIDtPwAYD5e1LG90dnnY5iB69JJO964REeu0OXtZYXfXZs+HeRGEbH5wu2rHB+LeBpZsA3f4rq1WgNoCj3QoYtip33jq+d1U3hzOmt4KummMwbKs5DUCmvqP/V7DdDuc5y49mTU0R5Z1YeVMIhPHw28sqgqjMIFoIi5C4+fyBrYNXVv+WsypszQoLcIWN2w45ak9/z8iH6GaTOB3qvawNXhHAqMPg+1VP6b3XUqaWTIhUYuLiRviHJzEOkr5dYsqvvAfk6cXDShoTvzzQFx2Yhw7bkhL6eXSPOdPF6s9Or2i2Ht93AvpJvbUZvED+I6obZSBRQ8Z22OGlReZ8Yv0RbG9u0xb4sl39R8WGTPy7Go9InBMl+rqv34EQQxY9bOEkzCZVT9nPNXX9wmjvDZgQMlrWI/NEh8jon3zsS+2MFNI3TlC5CH6TsPJERKRd02URBg8wIGCIXhfZVVJFv2Z6sb4vR8Ohm2aBPrWqbUFnMBZf0w4Ie93nYDoGQlCNTxxrnDQCXANlWehdMEUX3QGH/vqdAOJ5iNyWPYfrGdkKHsO3AXd5zMPEkg+sBCN1g3003bqVy4PgRRg81Q+66kcv+8tqCizmlWHsbf8RuWCQCaSBD4TZS+9rrdRq1hrGxnMOsGzai/tz/KmAuXABRBxvLaqFrWIy/poy9MR2hiKX+8GaPPU/TYdSEJlUFjpqQd/hVADbgt5Msqqdr5t7YpYETH1IdZwPwqwHVTri7CdtrFcouvttZTugBfBXGGswSja3gaPm6ydTOdvcTuZxFudxIioxF5cgc1Ux5BdDtBt/NQB5I7PgAfX4S8E7IMdE6hj5GMLwzGGgkoZoUy6PTi0ZJQgU2mbehGbMoF0Bk8i+Jh8ert2saZfPeyMSXnML+L2sIIDVt7ivTnSnzCOyYxaggrIGS7Gd+ipWsBdR8kQQARNcYveDd7+3yzpGmKo7GObK0TQc3Ymd9pqTTdUrcVRY9XJ8ZR5OO571TjjfiA/ozROUvaseu6auoytjI62dg4Z5/bc+DTadlgzh1CJLtdTyRNL9v9dwSsrVNjBtvt/QaHjEya23710u/nEwl/Ug+lH5fLNPM8QUz8bVIgftqoFumy2k2R/QjV8uLfbEnZ9A8sK18+jhPeKRevgRvj4zO3Ass2md23gx59SMz5E90UgeYkvZVCNXjIS/7wVHVE7bI+yAZ5CSisoPSIMUMciqPJlXnoCPkzKP6pAtcY0c71CZyJQgPoQvi/oIvXVSr/P9FFfqMLA4QPkDXkxTQN+4fr1CdGgGMhf+UInXwOHPjfeEbPycyVUpRh8uOiIthQ5Zdr4dZX2yrCsOgja1O640IOmCaYwcuQ8zsH7HptjKddKnbXoThBpWVdHzfHDLKcu9IxVyI54R38F/oDf2ljG+v4Oeh/wxacJE+oGe6TIa91snY+MGIo+vn4if9Z9QvwF2MUPreJsUMOUwErIc+Whr7kaAsPrL/PxAleTDs6cwVvrdCwDOwcD/tpqyayFeQbuiL25KkoYP6nEtBbRtMT/txh/u15HhEAuUsKZF7JEZJZHDiVeRAD5R9O/RnQrrAXpMqOMXO3R9Sx7/d9sFdZGs2NZU/K/VE0BzjKqBcdv78ICt60kH9LQxaeX/9HPrOpFJD3GPBR2QNLeIV0mOyc5tfWsFHug4pqByt/snnfjg8yeOhdNK2V3083D1BBoRDhIKAKrEL9xxOazPGwGZjgcS77q9Yk5eAH3ZPveJJxlvn/N3+nN7qJe3zvGe8R+k5SljonJGkizm9+ccIZURZgHZwXEYajCGLWv/nv6YHFiGSVmfTuEK6n7h/sJHEKij1SvD+OpqjmgzZrFGyb3UJBpZFDYttID198OyDSGHE14e+g2oqhXkhwLWCSNbriWdC+evkFmk03a7iRhnPd/scooCk+8H2H0FpzcfpGlveeYFlWv/pM5TiISrrAtG0700uAtr40xt+CtLcjd3iM6AAiCOWMxOx9yO8fN8UjK7AsFFSWFGMbIx9AVRil8jfs599nrRkEQ7SnQTUpxGos4gR8p7WtM7kGf7oDO/NI7I0ecH7OuYkw2Gtrq6WvDS8A4ZanXo36JQzt8+9cWAuqZBMOMS0/t1zWCJz737gQUQhZqmEOjX3kyq+hhvvVMJDYnTHclkYbchLFd9JSGmL1gZuCawj9IhZfBLibUMuFJa0OIO9UVWLM/M5HHx+harHW+VD+Un2UOfm5gIWJjUlrZOUGp9xOI2Q6eKYpchzlWxKU2oqpELc9XhCAiO99xkmArSOiuND1VHyTlqrX9NsF07MuG+ImIm0PZttuz4NgVcRtnuw15mMCRJkak0d/35fnQ4M0+0qtScqaestU9Fx2rtd1Y6nFhx8YAqxjyA0Am4zvcfRdi0KUHOFBfaOTAVAL4KGe2jSr3kZkTOywvkgiUjjPdg3xsSfG+vzQ3OAo1sUtm3njGQSbrTft7di8bW1N/aDfzhrSfRHMH55LDqVPkZZnjSk/AV9lqcQA1pKObrotzcKUD2lgBehyu0JBIdrI/DdWI6rHyd5Wl/s9HPErfpMijSe//38TTKffx+/sFAC4q1XXcprdb/7ZpnBc0W6NeeTqZrGa/l0BID9GYDPGOExwn+CL50fAs0c7sUkhk36CrpIDbR/arHLkizyZIvtm0OMIR61Fr55PfAzWzyF/iJjCosAgRrx9+63/OTszijP0CCaX+0++toRsl0EavSHz12070a5tPQ7PNyNGieM5i2bndDm1ggWkyaU6AFpAl5gKbhz4FVhY8jFZjvvHA+3/qK8fAwi6vj6xjNQ6Spss3lOJ6VgWOWNg8DAPdyU4wDfbdOTbV5tAsSnPyV+Maqo3vgRcQXPSDN7TmrD1FMY36o+WRV6orR2sJxdYJoz1bjaK3CKwkycUXgUFZf00DUWwTEUvFBHE3eYugU4Cwx4KcET0IXH7t6h8ALHo9fF5LcviX2NZv+gK/2oqmT8X4KCueaA6kwL15/yGKrY/tc90cPkhD94XSvr5FiN+htUsdxDRHp5i8WC6shEgrdBHqMijV7WXtLFcdiySyJVBioRrzpAn/stRpSEe0NFXPzqSRoUFphALz/3LeFk3vQiMbm/suqb2Fryp/12lAaZ9wgeeo7CfHbOto7pdoiCR9cSaMgUa9TdBqoHCr9+60PLpvqRgZuOglj7CB5vrctwKYsLgAk/5x5IrccdYYNU+0PNCEG8IUaK5ffrfU5ReEIPUIN/Dzz1KsOCAYMdFC/ibkTAKQtFjn87VMIFgBmGgqKqqN87L6hW6bd+PLTaJpQaZxSw3yasD5KRNEwiGsasC27FU+Dnrlffo9vM3Pp8slt9vnDSYnTJDhMP6Iew0knWmMlXO/0SeOlBTpkKLj4GJ52SMulvr4b3G9dtodjcCsFNNHCbCJyOefNg8nAZeOBvCM/S4LjfY8kYw0xLKWdeCb7V2OZS9QDhX05HjCfQwpu4FzG7Umrk/pfSGbt9S4rMFJcH8OzdDtjIUWY++PPvo3nO8ETPTAB/bjGBpFWbXH9JhyonhOKuS+oE5yHZEIB6cxFIMMtlwiMP46wyzKTAM3Mm/YtX+nnKG4n7y9kB/mOhp3aBDGvZF84xaZKCDAsFdLztN5UGykAWaLQWoiP5SxHDBxCUnDlpFTF+PvhRA/aTJ2R6SC376tUwfLn1wGE0x5Qt+uR+n2MuDix9MEPsD8qmzoeGHOOufyDxW7uheMa7p/e0w9NVvUyNu30nyYSi17pt0+I++JF+w85hrz+HGfl7Ss1DqdeO6bd04K7rB9WeWprL0xZbg3sLoTxbHNac8HLibJwOlndsChO/0/sj7lWzXYjx1qpdRm0UZ+tRb3cgXhWOlbhvmxDAHHXJulLNcKipmwf7BdSNnFW5pYTAMo2u+IYeV3gaQpAjix+KjJUrSE7AWW0aGCwO3WFZBGG0ipcUaTrMF9IT+Wv4N0ZOqY9085pROYRowtcl+248Xa1ushMdgqGbxVG2LljVEgOkyyoLLP5ZphOf4Iwod5QRQ6T2ZWBNtok8G0fiTDVX/H3Eh/yMjV+8jHwQnohuKv26HoQfT6nxjBCCt8S8WBg9H4zKbhMgKgbv43Pxg1OwizfGck7APYs699pfSfgxOk94jYaqYXf/AKW0zMKNQRbdhZE+ximPoXtVLf9Uc0LJofk7ShOPlO1v6eG1jCcaFG3dcRjkaZMr83ZP+fECPNSYbC/IL9AkVxbwq0i8e68oOXLZvl4IryiYLhUmeLzX/ybJrXREEgfdkih6wAhVYtiqyTPbe4WI4/raPSPnHS0/9V6R5vy7UzkEU5E8mA4S5lC8rOSufDGZQw63OGEEQV2UAA4M7Fc8cX/RQJbmpymS5pyCOkX4lbgYbsgjqW2Vc86jDrlO+cmpwVtBjGFnWzeBkvRkzcHWDjB6Al9igYg9yhy4PPRBAasqcttM/uo9SS/1jp1C0GydIhYMPDZNTbkWR5yBXcz9LZn0nXbw6E+Wmlh3XuoURxA94UFv6psU0WiPiea4AopOlFwrfSWozNnhB0ImsrTdLNnT8zexTD70qDqdC/4KhbkKKVABVlgowSh7AfDE7f3uuCPhtejCuoJRyEyVyotoHdbwzoHMwBE6kLN3N4pOs7W92C6zLhtyMhYNBzc24L9z9LlW9GoIgQOBRDLrNqVFbVFDdZt5vq5CIf19jF8ayGt22hPZwHDg68cfIUzi9otg3Z3FD56P8/e0DWFgGJFQ7YzqI2ZjHxs2/YQz0yE5QTm3ZnClmi9A6X36Ikac39ffOWc4ciLU1p/ayQGlKX2UNHBfSpLycA6V0SfpWfCkaNnOEYQ76Z9bX9DZw6bEJzbTC9wrm4+NY9dlRSOUG/1Wr623cYLffsJnbBgnWV0HJ98RSMJ4rODU9itP/s/NFnB4F9x/NrvDb4cLBfLfHyoP7BthSfWfmvp0nT28yIHzDG1TSvLnyBy3iAP19nHzfiLHbPXhJpnKc0TiwVO312aM69pWYXATCPIHO3X/mxTDF+Ujb5/uz8RJz0oSiqNUk9TAffzrGNpBxKLNhgyfhyTpYI74L16k7crBh+hS5s+jNGvTrAw6PED/3KojRKFEtoAV8s/TepS/DyLIEG4ZpKj+yEZMkmlFEHyZc7/RrEgKUReD7P5g5BqyY4+Q0z6fGGc/KOCuGKYtWXMDxZFmpWWI9cJWbEYZt6PkLldsHPWyR3Px5r9Piwg9po+9wngCrJ4N1yH8s4AIsuLh4oop0r63/zYMZ+toRsDZuG+55981IQPAEEsW0h+Ddb/HDk2RX9BKKSRGUdN1gLoJZtgzl9nHoXsnkK+CbGznSwRaz6ASTnc04xLvUpf7mEM2WKNsmElVOhr7XHpnXqsTx2kzTMIB3wWhdVlGnIwIrKta5ak3Yv8/LWJb/X8Lea9ltZAsW/Jr7Dm8e4b13BN9gCIDwIDy+flCU1No6MxO3IxShjt6bTYJVa2Uuk5mrnt3+adWxSy6bwzWljSwfpsVh47KHXwskCGzpnueUIMh2hiFMjn4tpj6ClKs2bdh6ZevDDES6m8IqPcALVoGTqMWNbvALRa0tr/jUdtPtQ9+w1NdOLhMUlrPApBIW1l1XqSBTRXAKBzTtdUMrfbZiYo/1FYw1zrPBjC5cCYd2q3ynUgWuALpCSnUt86+337b2Ky5OsQL1HUUkf1k92XpSIIP305mcKzkwDwahQxaRJ2v5VBuZod8mnLxLR0kYhUURX3ekgHuWzKyf2/tTmAUz4AdUFY5GjPTY6DvUhTQKTkw92f+jsrb3MT/rZm8ineLW09QI1TT4IFTTvzP4wnX4m5lQKfIJsH97o8CDLT3F2DWheiqdzUMQQHwgt3AHfUfD2FlzbdWks5N5yAcvQ4CgstDASbLGFRpoaiLo71P4etl0A512sc0zjGTZ2MHW2L/PG8BiyVv4fFKOADqZa68NYgiy2T4dtArqJOBl2b/szdG/u2Qg8FGlvAJVTzT89cxZurfrclx1/NxDr16+8ip34gRxFxtrLLBzvHrcMCWFBmfLt7dXLp0Gv29+PNGDGolkeLVqompf12lP56aYHg1VG7AQwNrQo34pZQWPZLt6wKaIHrxVo66v6401FxlTqgtH4YmQ7FfkrQxwkHAlKXoZxbqJ8kcrzjWwbtIentMcLer0Oe8bxIH4H39amU5JFA7fxRt+yG0KSbPKXfRRgzqSEU8fy36rtcsVAlPZYG+OEo7LwwG3bbpEb5d5GU3eJ1nUHJ0mDMWnbiLu08EjO/+l9gW5Dn/Hed3dtvGVbvcvUSkuHpyFLwkFM9nPbTAfbINRDLbj1vgZJuaVNYqu5/j9D/yGkgQ1uG34ZKSPLaMGVtPtxMXsEcgaaht5tiQVhC/d9ohtNPxuUDM+/DMJzvgU2ISiV1kEnYPSAWeLg1KF9M22pvCkLP9DzCV7B85dMzy/In516lT8HWSi9fGfTbxjXo5/VpC+kfwF2W0bZGbzYC3rBDyvCVMFSZdrGD43k+s2yySS8Ah/a+ZFDxS9c8n7vRbt43hEszj/6fgNVKcC5HQ1KpQ/Tz2PntN0tmLxDhpvabUghBwMWAjcqYh8bXIXVB9c4Xhp4zvL/X4oF0TpIRoa7rcmzds17ijtf3VhpQHNTRilH3TUCG+XRh5LqA3lSlbIx+HwyrRtUc5er1d+o4xECY+q79lAw7NpA56dnzdGN9n2+rBk3oZk/MT3UE4sahGm7suSNv2a6XVtRWY6xdAvspHbPKtIvKvWANU8yZtttyBXvFv59Vj+zM/MTXjJfIgs6J8oLK666x7UUmwcL2PXbCpz4NXlxCSUGOFhgLXSsawomBO7wYU9Tasy49P0SfUA9Ex22MC1JzwUc+HjIIPvlVx2npCd8OEP3+4wQipejsXBix3UGQ65jbpe5IZUEBme1N586x+mo33ArYzDac9eevyhYccLHAxB8SS54dgN18NtejQas3MOO19iJkNgn7C738I/XbQRdNEakL3ZvJy3g4wRy7fhQGOBz835nBrH9szeehNjidy8KwKIEODYjOYkfTLMp7VVSU50ZTSIfu6lIohPPEq8sqwZf5vzoOgHWRo+/Hw+WdFSh1uUNKltBF0yp9yesUrfiZz6bjZprrJbxJFn34EETF7X3VFwYWSK8EDGi4SLU0vL/Z+91knguaLKGz/PMFPKXwLjqlO03PGK+zzA70yPBxck6D5G5g3dEdQFXWA0iXX7lSzwDXUCFaZv5hCsfoooPqN4I7hBR3nDbnHAJxG04vLpqhPSnmcQoWdlRK+67z/c2pMpE7q/P+jvSaxH/untuaxaUmBlG9FH5YDDFtn/zL5WYGYeSRGMZCwK9GLRApPlvmZufM9m2eft7c2pUq/SVJPmJUbCAH/MrNUGLTqSV7rP8h2ndWr36/ljBc9ey1O7CwrMC7lRWbTmbGOLziU7Wd3UvgOcBxmY/nVeucny/jraEUeQtIfdyCKm4Ol98W+LaSHAQ2maklnDHZYSfQi/P3RUq4E6rmW5RYelHPnMdZs4udLfwNR+IyiUsTusZEfvnJWhDn4tIZw6UZ+i2uRMmBdBONxzZBjqtPZ1rxBY5NeU6BtzrreS3c/14bav8k991NnpIuzKNi6Vn1UmZmcHUBn76HYViRL23iZ0ClmcguUMN87Ag2OXPlLR3CB0HlYDmcHQuQUDxiTyHurRErQLGDL5msiHL2nwEy6VdQd9rg/bPe4DIPqGCOPY7sBBC1RGHhuygUaJ9hsnvuahWPKCtKSDT585jD1+VSRl17xPbZsXdljEDLunn+CtUhqTGovJcCt5JdgXiD7MsjhB3UbkrR8bhpPwZPLBxn3ElWk26u5YFIrt5hL4jZp7uV3YUL6JTi9vBSBYQu2fb+So1gG5jvw+oWQytRjhnwvmuVwFZ2VXJdbvdwxvj3Tq+36awM3tAbr+O1+EXK5mJUNwtUH0fvdfw6viJopexdF+UREc4F1UvJwto1cEW1pF85/KksQaO2qsDY/qC/ohty0pH+Z/1ZIe2mrKVDOtvW7SSpNXWxGvz5R0IuIcj2pSkU3MPrrjrXqHctDNdnf/ek8i/B56VufawVL1GbKaHjnizJKy0XMF2XqlpKdlsts+syh0/MRPWY/exRCpYfKttZvqtGhFKFhuwSfRULr7O2fhaePZ3mczva7BZ6xVLGFogRA0Qsdiel8im/DshvrOfMyQCupwSLp3o8lETDEqEeL7PpzTDoqliP/1v+FzOEE+IzKe801+ffmLetkIoF698gHyNaf5hQZj899mr8jBu/G/228xYG/x9QbQLrOy90P1ktrqhwoTzsE28lczPJKo4h59u9hh+uvpzpo2zFKwzErX1SgiWbboDjMMJyBmUl+u/LFBSKpBbqxn339PzONN62GdDt89N6tXkok4U7ODEjEPOqEDfbRwYB1rK/Icpg98bPu6+i0iAfrw1GPDsDlDXyPvF6MFygRB5387Fyot0Az9dVC4/4zywoHnXoTbp3MLGhNomapWSuOQZ5E8r+Qjvwz/p6bTxSmlBKnIfKVPEUXE6GaQ2dYGbnXjFoBBmhZzR/Rv3Rc+HuS6vnJJPyoSf4YPuEJQObrPlR2P/lc5ob3vYwuT09BAGshXIQptMgjPLxxth+uKkL7QLWkYEwh7PmUVVERwWgXn15JkISasxFbEvzvLCNi9kQliQaaUZsSbI1XVgDX4V5n9+RruXAsGv9iB1EpgTDK8ZfMxjcHIa/iYAOvN0lz6L7aeFerOIa1V9O/38z9cYWWrjrVvMR+C+mZYufmAkT+D6G0Ef9XBDjSd7viY3rlHOgnzidN0PnoS+ZzBmDhJprnsczclTvjDptsdgJZPtOAnFoL2Ohz1O35qTyB3+Urh5qH+OfFKC9w9wOnqzzUVY/3qCJ+RkcZPR3yU0HcA3ueBiY0AW9SMPrk3LPb47FK/9KYZx6lBHE+f19ha++++OSQNxHeO6T8U7QyCoOBRqbp1q+tXoFiVCcx+WD+VCtxr+snWoMLcjIzUF1N514U+aze8FCjQ4vwyp+LxjDYucJCkxYyOraYaVLquA/XudL40GAfJvBcXhqKqZF1Lbx/03x6QuKR3NuSuk9DmGWe+Gk+lerNFcE0a44bPL4NvR10LiQCO7xNplC9ET1T2vLSvqNsVE3m7k38n1ysmNkv5ya9Pv4ueaS6EfXsjCYAUwKIOUhvjuIf2slQBET4Q8BrQ1+AMFxzgiSRuCQwlb8i/4ZpkrqKXiHeWd0+0pKi5rMnlULGk9kL11PXPNLysaRzp/aog5428i+oY5psf1lZz0BqR0yRJoiJ70BQmpkxQ7Sp4Iz6wgBbBbBeEi0HBcdpsaxhppb5i/fe9CPF389lPz4COf09lWvLQ96Qohi/jWqS4iRuNLlT38xpGi9LBi8KfI1hN5fdE32e1CcpD0X785CDxPEgQIykM5M51gW441VifJzKJmRZWv/nl/YHkKwXlLJpe9R3LjDqL7a9OxasoSIz81OOgCwdyI7uc7jXDHKI66sQA97e2Fc3MBjSQIE68J4/yTj8H70SBw+8z8anCQnz9rkihxKMaiM8VERjmpGiEyiC/wuQSPsBADfpOpsP5eqpQr9T8iDj80Qk9lDQ++VZX3pA2NQsNI921yBr+sIjTjMIaqxhF7lhJxT/d8RJgdXjvLD1yyPlKok8xqYceoYT8nmLbZQg05FvlgH7X1NXYTrXtZRNWnT0m6JOI2NwCqUGMZI9OFiH/ScYuPnYXVBWYT3gkkNPTiR3rmpc8sj98GBinvQMnp3fvw8vOR8metrqckFfEVf8Uw5MIKDphXb3ZUqbV2g92I97NGA2inGQT2ePgvoHH+IEoFn85fIG+uVkMlrDo4Bspw6jlfqXss7erUF9nvy1GF6R7P0aaJZPlPDZXuvn0uGlN4Us4bbzN7Zc6wQdRjrOVIbasYwYfX8Uz/Uwogq/BHeRzDxP/2UzDBIPDp5fzuT/MnX3YvXyF2EvTdRh/sEqD+vV1uPKL0tkjdJlG8uvPqMq5gdNzR2WM71/noU+oKt/0wZOO0klWQ3Mg1eiO1qhuCFhOjdD3ydR5EfpH9SdD30ArDXz1+etlSaL+6U5ODuE8jZxAC+bW5AC9J9NO4ri9fSwSfbJ5eP/fYFgVtCPrU1wK5oJIdQtngt/aRY1jgNaKQoLJfOJRYl6cj/RrK1b0FfJV5tmvXuLlZ7YoQPLT/21X/61M1mUrp6+HPo/Im/Ia8oHThJSMs+ZaVvgJHq1Gv8xJgHArO4aXz5Z7nnfP16Oli6ecjbwO8teuiFJ/LLoWvwqr90jT88jWAhEeO7g8k82GoG6sf76mT0sOyQsVg4BtbsxodgLVRRpAgQvhXs9c+Gxguyhb8/tNgU8DdtRZ6/75zETQBLe+098TwA7IwnPAW8M9Nzc6rmf9UauPeiU2wNHimAU9y/Gli7E9W9Orc026XWsN3OERIGNBCK62vGTTiC0mpJ2mBfYzhLe+GxNbYWPeYfQNrUYGeKmbwEGWRYN8/FtRb8E7o+YnV/b/0xmff3XGkzfw1BDPQIz7FgSmdAcUFz55aDclsRS/k89MdXYbN2jVCTs5qfo7zCE7oH/UxMF++vwEnwS5YPCeG8JMv6Lsn4G/bl7lSAS8gkRGh2ML12WKtTFIqeBnp8GhC+NkKDCbXpR3ELa3bHxkNLR5KqPwGff8LNF0w71Pslyc+4OjG4xyivFuY/xMth7vspFtF0kcIa2GkrMCNESy7/bhVoB6sj2rvIvTefhwSboAIRKB9zbmZxy+gYoozdH2+DRaocUuaF41pfbhmvqP+r1ygpDX082RPxBGApPy4MOLyianUQlOqH198e/22J7lr5T8pD/TjYLFAFuyT/tRX1H4ER5PYVE0fvjwz0e1KwKfNof0yvsY0c+z8rHOrFR0mrFI5Uu4TLYFlBSzUYKgjXhWjwu+L+seYh8HEB7aNG0ReaSgZaR/WBcBcwQijt03n+fJMoxj2G0u/J0Uhgq+cHVmrh1RJQejWfloCZ6ovsEreEh7plvJsyXiOBrzG4mmv9oouTADnxI4DB2g7JDqsG2k2Z95i1DQTzxHjQFaDx5Wux+1h+TrvcAjSJC4s98IpjYXdzr3+ArrqdBMHKx9khPTPZ9jIH3eQwY/1wgoyT/1YvyAdIvgXQQjX21pqooesHvnJV5KZJ2DFtEB3wBM1pP1BdbpGjdST6P2ZPuJBpomVjiE9Powcehak65IePtov4LmYJxRdC3H+Z1zuD35Ni8XOTKhT6Tf2SvjzIreBILOQWwARTpcz1dy/XYYp6BNy795RnZlh084+2WiWFIMny5tRGVgCs8JvFxjf2sBOqx0UNxBl4mu46OQFWeSLVPQZ6xcdW99oeN3mReeJ4XNe+O5+EU/OpzSrmR608n6FsUptc/4+jX1tYGpLyHAkD4rLuDFLf7GlVk3hsQ/6kgBmPCuJTkTsvI5Qj2uXAFDlWrJGGGEmZOdHUbKTZntQRCEpQTWJstL+KqTdEpH37A06V9Sf6Vp+jLV+qaGJE7fHwF02fVIfoIlvw4mCJQxdhfrkENdNBs5LcEv4Cjn8PerXxftKoRTEaZxOxbWahnQ2Xvz912/7MHifHbSwaALNLiPkvOLCSEXuvOpIeK7Jq00atQC5a+eHP9HT873a5q2mppIs8r8rR+n7UL6RPq3SnUhXzyIhmHpSsWYMsJ+7ay9XkFGSDZwvf8/wGg+L4K8jvIPZIfOLp0itE6y5l6/lKmcSvzikcjs6MWrkfAwHb4OXhpGSEQv3zDEQu6rLpqs4l7mQkhfv/E/PZXy9JiM9/nnmNWQl2rjeawB3cOCqQdvr4m8ybIxbs7CsIOZqZ++oxw3M6PWYttSFMN/sUZR8/vj/NSD1x4HjPb8vO5RMwNfYKMGGJ0G5/Mj0WCCcV0//dnO9snF7zClv81qz/6thzUCRu077Vk0mHBxcr4ImFerY/4BWfloGHv1sy/pcH9O2uaMOfB4JDm+ALbVYqCqYDSd8KdeKtycgcJlpe1A8e8ZvZM5+2rNDbaHCysY46aQ6xlcr7IegtIyDhSWKXDlIPf5DSQz/oguNS7iJSnvJO0y2MYWFcH8UvDbix19iTn0VVVxZpkFv3qHQTGc1O7/L6N9JWKSh0bdB7BhhJ1uVSS2Z9zFaPmhtWZYJUbfP1yPAgPf5LYA1Tkt/U6CA3VjW63dFdF51kxsYVDG/obbd5bQNWyKHo/3YMn1ET9kXVUT+xcLeouEOeWfqDrXyy/WSNNU5IxFe6TzXm7T7NU2iKGUAFOBn78pvdJvX7/H8uaIU5rmo0PZjEzbmBPkr/fr4eI7SVsSB4C/Lv7QV48El6FR8hoxDzLcFKjZiiIElBSgrvl45cCCBnbCAK0r9BDmtTIBoAcRc1FlrRPewo0Df+neDb7Mkay1r15/gmPAkqdO4BHRlZ/aStjDfBcNgpfqc03uIGQ5vzLbd5vPeudgl6zK1GelWTRN6wN38d0DqmLrTv2v5n+cnG7AC7aqeaFIbOrYW5xZxhsz+aBw009iDZ2JNWWm0+kQSW4WgiBQamoPeVz491dr8v6mTvr1K3MHxRFv8xvXvReEfNRV00FJ86Pgc5sLwsgOAjW1/cMjBbDJT72tksAK2w42lDHvqMDUjatxZtFBiBu2TFcZF/HDz2WXdm4ubSOn9ObKu6zqT8Gu9QaeivUbWy2liyWgwZQW1X0mMHxMFq3kca91/UMxOcOxu+ceZfMI92uvQhyTkLKWup7UBRAV1UqCIWaTdV1oD7+2gZevQrSW8R7pLzBdZekc3fcV9TPG+hH/vkqQd2BJXXFtoS7Frhs3gqXDcE4GBnLxb4lnF6ll/HwO2PTlq6rcIsrjzpSul1Uvs+D20Au4tt7cAQIDUyIVLxAOMEw6S+GCwwZJHhwAQVgYF49HzrH8MisaDPgt6aHZHE+SCgvTRdwUOvK4D3VzPmkCNb7wHYqbud83E/rOT0pmwGKZTz2kRDH45L9P4kmuwVPCplUKu+fBvDw+J1AWGz5x8+tnHD0TLvDNQWnxr0bXG3Ip9qV+m6FGKXDN7s72sK3IA7WCZzN/97l9lQOFqSqDaKjInf21S6MLnT/5pl3+3zQcYS1H+deczYeV8funInQm6el/XEW+nnyS7x18yBhxxgGmmuPS/mhxdsOUdZmOb/1PrzBSl+V39VU/+OrJHmdqhGl44Oal7cRbeyJLT+TI4FptDklV6BdKcRSfbplzUF1uMVAjIuKX1fBMPxSQZBF6yuM8QpLlvCJvrCB++LELCsGQ23rqpNfiXaa4mBTglVTW1Fnvl/BnH9TfzXdj35+h8J/iIve+S6iLVE6R4gjpRicXnmg4V7VRfw43cOVw5vUkId7oGEc1uaQspvB+IFcnhQEBoox3mBHWX6CFJjHv+f7EA6I/cE0B9hLAgTuvCQ0+WkQ2NpzbQc2apgE5+X/rn5XFs38Cc6P2SIv7zvnGt/0r9IrLS2M5wPBWjPtjaWHjBRhLBlkbOs/X8wmLhT7MNyAq1tFLwzvHUv62Vlf4jYFtNR0AR90v4yyZ3ldOkZFY6ljYM3TpDnk4A5Er1IoAEhXXlD2ltBP9ObdOxlLCR7vxoHNVjYDW8AfMr4nq4CLDV0VTEbLv9L+gkB1ieCYBOTcvOR3gBtbFvPRpoFjjKeR+gtIg78ux0mVol4aTMFRVhYmtfSBnWDyH9p7mb/6hxmC6eZimspIBdElmLAs2hPQnnehWiJur7yFb9cz83OTZ+LmVg1csY/GD7M+nnPI3npBrwr7zhDUuQ95+aDy+HnfsqPRSnWa0e2ss7K9YCL3s3HTAJQIls5rKiY2eQNHjUuVdGYWcoxMag7P2mecmGixxzJTurnQKmh7UXLd68Wz0caNhSZLNybH+dkjeqsQgVXf0as9LiKDT7VdbC+qpNGB+q+mrlRVLlyfXJEM8GWkbv+KvG2M5dkCqCF8jaWcmj8wyJsM4QeneJ6qC8GOWSRgdYVqvmF/u8xmKxfJwr24yrokk51/ZIPrl9KUTQDEiqYvmcd8shANxNPtnCteYhcBMYF5/L7gVGLDiI4qT4UaUqVTFu4fZXCSHzA2kGEsSmPdfXm/S6OhggD/ga4o8rI9Blr9All/TbF28O1GIlyvehAGKqRnOsfvWprnQ/Ff5hxiKtZ+FfmN5E4sAwkxdlvxsnnsjPIX6elIxsV7KV+vzq/ndw561uvvqVvKlVaaMcIE2xEyQKnUq72XfVFNPH6fN5BQ/dfCj/7RptL3eeePVytrCZzYYN3alOImFPZt8N6BYuaZV/MiniGQm9ZFc86lMQMfCyGalY5CyALj3yCtlq//RUuK+fq9+MmE2h6/XruFrhjv5gCarnfHDL0DDrKA2ggddgvupEfjupCcsMS2YobCDa78FpgzA62T9u2DJN+N0ZOytkgWo9QcYkrMr6M2mFUAPz5Zx/s4x2Uo5yCtNbqKBzWdt5+vAPpCGT6b/JiwUp4479ZUuymtTSt8jTa2sEf8xis7AOBoC2KWIW77dZy9M6Cb/UpYpLreALaKRKu/YtpTdQBLdVj1xNGiIB5VKSITLEIh7YVlm6HDfLUDGqTbI3PqTehSparnxhznnYArUrk8kyi+7UInN8qJfDogJiT0ZcHcZVnUFMfilwF8ZQIssxvz8Ztm8wOzUR5bQdwh50rtWnHCI1IkUcXE2WNtjJv/prXSTRkVIcoIIm62k2eUvLfETnMTxOVLyHa706cVKPfoct2Ao1ouuhL94zXjrpUVu5tj4PSy+UnedzPsMBpz7n8KiU4FJFU/OeAdC35c3YPQd245AaQTOBIULvq8fN9c6euDIMK8P2ZGQ9QpJyF56hHs86/e4D2Whx+02NpPEj1WVtNV7uDPZ9wbGwLHmj5b3el++EnpUfScbRwuqR0SktKiYntDXj/PfWTb36wn5tlZwE+KTIV3CHpH5xodZx0+YE0r32xLnuUjO8hn4NvOV5+oITclMVXlCWJ+76aZ9axnf6CuCAbGRBUVA5rk1artWZMKSs9mZvMKh/nOD/tOg3SDfALVLoRDir2vAsvBk9N2OeobLm5yd6Ybrl+AVwm+0I+3CJf5GOwXkTpIt52ILxsaa92MxGp1ZDYG26T6mMWR/fuwBire0GjiOih/HRgG0ZQYL81xQodet+rXnHl7guokgKtkCJyET4MaHV5/OVSezQpmY7fwuE0KOBFli9NehVmKVr//hoidds7fHNVlr3pjPklqVJ/KeUbv2WIPrJg/UcuurOZ8DhuD3EzAI0VEBUrlzX0IGUz2AGwp4A5S4a8CSd/qlqq61YeWnB2+xawrzWb57eHKktgso/pY0CW/S09QOdTU/Rth6SZru+03YvNSlKnP/MyVAMscHh2QqsoMnkqbQ9WR1BX4a5gCCBK0sw3uTE/t6Zq+BzKcTDJmX9Tah5stlFBcC8uKgows+DE22USYQidMa3yk+cHwK6H1ze/mprzH5G03q/4smm92LevI4hgd3ts/A/HabFRUgDmHALbr6Kg6zX6EnEj0cENuogkl/9nvVr4Z6yV1e1vfKcfB+7OCV4Wj4GVH3ac1Ed4GNuNmOegqnzKA/fD42787vOwVo7NLCxqit/dDj8EI4cAAn7LtAzApeohsbSEm0TvjWU6E0aYT+1LFugF++4xRCEQYopZZYLOmfvfkc+vvjpwmgVtpDW4muePuYt4HP5Ci/f7ni7p/f3wSYnBhIxTtZe4ViJAmnAI8qL+FsU5Pl9X7O/vc5Qyh4qlBBdRGKGV2Nk9VKHyPzvq9Y46kVEdanG0D2w4CWB1z/Ph9hqXzYA5vy7rvH2VY5MiZ3hDJswds4KKzIjL9aaTM2oG8xIsLA/ajFCGC74o6fjFItKiGogVJJCPE1NK74ocIYOlNyCLNzX0DBJI6XALVRZHRKnMiN3SJk3ys0P7LKt3sqrpa4sftHgYMtLcZtYvwdCy9x6OT4AYZrm9NUZAe5mdSidBIGkt01dRdS886GFsqBJ+JVcHfkagqOZhbvj0MlxfjGk1DJ/oP5XSXsEpQgntlZnX2DrCGhP5o28S+zPs5qka9Vshdt9lEG+atkmHHP1VbGUTQFb4zXjyY7U5xO/dl8dUkF5ttpBkWjIMC+9gY6Niu1fWOgKrG8BixybuxcIJOO4PUP76kZqIAzELXFSEwf/cDz59QKFCfw5WP3m1h91X1BX2vpCFWDMmplh2PGNXP6Ed4sVTMFiNj0K0LKd4pqDvUD7+s8ELGd5Y58EzIlbmoFs2JU2Soc2eTZ5tPVoPs4U9M7xiYQ6cSAKxJHU1/qH62BgpXOTADFidJG0RxHHvzJU/cVTZ1iaexnfH83aGoMva7UU5fcPyNsCL2rf6b+OQZl1gRtAsjXxnJRi0UkXOO3vuhvd7ybujy1TysV4JQd5uI24x8N0t+YVRt09ImE1hUjec70GmVkHa3Xj7dzMsv97/f7kTbPudPjfW5Wxp2p3syZjxJODSF9xhmhPNkRHSlFTDoqtqHAfDIVW/SXIukvRV6bEaWyOBZ15m+YKApRZNc9Bp5Kh7QJ0o9E/yyb/aNcVz9uA5ChxJddbf7oaK9M/vUXZzGUhMkoffeeB9+PrGzSJ5PFG9XacmhlU6mbEOsbdds2yQl5Ta7KzicziOCPJ6rvqW8mddDEMnvIFrOr41R4nWzN5e+naog7JwygyVVQhRT34RjB8vw5k7csKVRY7Tm50XeQ/qRbCOpuYDI4eoQ2bUfyfeqH2ef3v/qQiisyFjseCb0KI/bWPRPsVYkMaxz4t1VOgv0NQ9iBPOp3DfdVrJ+MrsKHEPad5I25MwgweBhpr3nf2sR1YgKqaIUjnf/NEd9RimJnJm/4O/8+sGuxE1NCT0mN+Ny35ncfHu+pRvrlnEnVHhvh/1N3oFAYvYMeaG42R3MlgEP5z3QQQOkSNlTOH7oym9blmeVx9HX1Cx+p2DkX1ATgAopK/B+edvRETJnXVQQg8GTk5WHqt003fs+cVt15nH89hXB9xP3N68noTmC8Bfg1duNMmV98LKh+oLoUVO08c3kweJwIhu+KXcMH3q4sAg/e6Uq5S+ow7elUN2VjK81QTVYVUyViLLAu+1EnnE2edMmUWpFdjfy3NiVBHDDCY2cF2Bk2CrR3CnSeVRVAUC3AjZFCHcinxNdn40iPbJtALi58F9uF/k9FmQUV5Xm/0SEA9y9Qy02v53flx/5Ov06+0zL482E9dWLJRBT5CglXOcGZUtlQf3GgMQjn9nRPvhnalWj8UAcqciz1W33/xWSyV2Vseke+F218GuNv3zT8NoV8DKPZ+O+3wTDqApI0Uj8r0O5z4CFre9iXbZIvTbCtVt0hmO61fbwc1zhUSGSMX7un6kjdB5D1d+18uZlqSbuDvFPtjMTcPEuzzyh6eWM49ldfyVMRFimlk3UfT/DYPtHywEw1opZZ71EtnDe2aJUcLtJ2XTvSf6iB/ILiInF/4xbF+OM5VIMdOh6yC6gi1Xiu8bBHbwb6MHPijZ2N3IY07fDJE/E+SPKIjcLzW3FK5/8mlIpYRYSNc2acbTD4Tbwm/7de9NdnQ4hJBC17X6e4X9ss0bsidoH9iW4ciTPA2TPcX56fDG8ruRo9BGd0HcdTVka244QS7nfS3KSbaxbWHvrB6FW7NJHuXTYIBqCAo17WxuOx2Hf70dNQB07GOmZgto66enLH2B+R48XUyvhhVHooOZie2lSan1+lFcOJ3ke4WMCzSbSnVI76mrgOrPr9oW3X3o3YyMGEF8eSdSR/7ixK5TX9DZ9f9VQ3mg9qm5FppqPcHlU7gg3/r29pyc1vs1Gx6FUhGpKsFeTQr4L+Ps9d/bA4lmviPi/gnMc0e0TyN0T8mnvgxzr67tJ8Lv1ksjSefOr86bLKBgCju0uQycdTqurMbzl1lpRBKGNM6UzrRUCsvBuNxIyC811rper7inCB5JocL1KcWtZd8Z3wGVmnDExjAv1ygrSppVNTPvv0qrdpYCyux2+utMo09TeSCDc/lHYTccMu2q9crBjbflBAhVhcs0XfvGQNidENeORZRWBgTwFf0FgRutKYv/P+ItR88X7hIVXBoJPb8snlA54FG2C88evBQb+QCrl4N/XrK3jsP3qvdw6VSvnZpNrTWRYTf3iHmyTuE6hjmAxy0zqePyLSrOjww+GLRDp7rgq7uIoDckHgDBa+br9ruIVCWls+9Yd/J0X3atgfp5bl7ijBOxjUxWq3ybnOPiygrx28X2WCCRndWMWljoQFgxokZzoV8Gfg3+gbb7665DnW4Q1toV+zp6jIWf/pQ0I4fHjoZlLXcWFBSf+oOsUgA5VCe+w5081UhzQlHOITvw1eZlwgfJmrQiS8N0hHGaZGgiFD7fzWd7qZUIku9hSScH0WXkJQeNlKFZL6kbveR9ykHZzmn9HXjOVLV3/vV/IEO2+wU3Q9SKe6EclkCIFYftQRMKkEE1Xi/As7Qk2IRVVq+1yRf7+nSfXEAchAFHMqO3n845Qyf5TU5UEa5mFbOiY97p92v4F+Yy33BpMPkRafgzHEwEHTvfnUJp9CJmBKEr+Kpi+7mEwAv/iQeJYH7JPvNrH3grkPs6VDSkz/Z/6QV2qMnytC0tQZJAYtnjViqUDjqIgk2o88mXrOvuLVgA91+59ocf/3t1kCxsj0X1FkBJDQ4qgL99GhA64T0w3nEWGfYE67H3LhKB3NlH+zjTY4hrmxT+Qgwm1sEQgdhfOXjsiffarPNibk1ivj12B5xO8nAU/sS27A6AIocZ4b/Nc5lCvl8rtLJobY4n5SHtyRjEXblA3q953LjVLDxFdV993IPndYyTljL0GXja0q5AltDNkbCJTK1sHHpr3ecBPq6fwNBHFPUqwi/u+kcVB2iKEwi34+eSgl9NzeYq6uVwgWkapjqcC0Lulyp4D4vjleqWI7R9CsWPqOKNYcTT8JHgHopfUuUxJ+W9mOeJQzjumv829lvxNwMNM/exJUpc4bDAIdMYG+Ojjmfs037BM0a5ocE4uGDEtV+IGDH/J7/utZlIMy4EPe04rg1ERWsp/5eQR+UFILukCsM0rKNHJPKHIhWLG15mqI8sCzqm+hJoM6/9FEB+EnUKnxisAKBQcz+MXXn+LDE+8eF80QTdbUt1MwOYLtUv+AOBrOimTc+T+f6LwzV8rKTwEJqvJD6tsCvttQhq1nZ5rwvE1tN9HUOtIwE/+uT/GlT/GSvSY0x9nhtySgpclS+Plw+o8D4NSPgwv/5TEGRHrzxoJtIcnJE3TpRW4Nodejfd6U8MvtLi7cXwD/vqE7tYHuYYbODott3JVlSLHf3/GNQX92WU5lZvZFUpDWlMyduBAHOhOSj9UPdiT8maIhZ+FmJZuiyBX+Fb5dnCceW3C/rwp45vEgkyhPwVFsa9tyhZxo8NgC0fmjZMAVN37RogITBFyWSDAJnqqk5mzx/UxlrMC4/xjmndi1k0li1TO2+llLtv4sc3OFW3kNiNEEVYOWnX86fzWOwc7GZlSVN12juyQtO02F5oBvpvnW/35XDi97pDj64UZqPTMRNHL3c+vJ84h45PzsIacyzc+9KQ/U+0xVQwzCIJtVFcL4OnEepsVzNnNjUycD6g7ycYifc3BELu4c9yTPmx7UyUX4DVMmu6KwlznV3ejfgaxb9jpPvhJz/+YvhpX2s9sRzUdJSeU0eJ4xLwrrpxBf3vJfttd5hePTWMVzQTNyA9p2rZ6lLBNA6f+N/PXevp+iXioICT2RaKpK+309X8JU/J4AgX8hNQKBP53UkLwZ5zltIY8q0eQP4/zvxv0ONu4P2UbpWZABSGoG50lhCCSiYhmVlkvKgVvdKdiTISHuajpgXhS+sRUtp0BoGnlHJYH3tqMeFyKhT6gjK4+FuMZVh+y7IAsda2rkmjSGfytRctkr2+mQe7Gb8+fVp69Un49XtoRh02aFptp0LD5FUfRpICfJBu3jR675FcPN2Xi/IBGpuzPW1z0Gve3uQ9502aftWVtR38jCSta8X2Crsa2fn9yxk/vhqKBeV2HTad2p/6Sb/yaDBEb6vcNtDuyGPpyIBI3P9VU/OIik4xva/ch9LBcBzDvzJx3UFt9RspM9dsRJqLUllTq7DrFYQykWKtplDRpX96+/HZjC0R3TQatEJxePQhPF40gQhCj0F8NlRwn11gwt600j9Hr7+wR3l3O2hF2aiPXNITSL2k4PxYlvHh9epLY7axuvGwAAQtgYY714wX+YklmYj0I/QfyJLWO4n6KCVC91IujJCsJG/fmc7lh7CCq7qMvmFAJReW1oAdYUd6Adc7/W8MvvKySLrXZBBQCB9OO38jPC1x5TDVsprTshEg+YHKevJyugj5X5gFC0wILv/qA9daYj/ZiAKMHzZ2c51lnB3LdBcZ+fVtqKJ12xXnjnqMXXZAU+TU8mClB6nutf8y+iU5JAFlqEAEOyYyZxPADRmA+J3JnUtOTHBcwBxWDiB5Z+UOZjmwGgBRVQvF9XH7Cq/KcmGcIUXToNo5I+OLoGndJ01YpT+LeX/b6f6pNkgjAMaQuKY3O4vBB8ifv4j+6EwjIb4xvsmZJ64i/PtUvSc3kxo2MAnd93xmtl/NIez9V26jfhsJ6e8V77WGA7+pKFzwqGH7SEwl5riYpH5rWs8eP172/PjBWg8B/ft9S8ekGQTwM7Mt9/uZtQ8YmZCF9JypYnOSsJTBkwkhMMX7VTJHzLVndm2RGYTBbifunk2b/sBNR5UCF/Y9uzqyN23M0/n17iFBZxpVN5+b4BL6+t/DzVREDGqHhzDertSPz8LibV11/v5J3V0qeRyLMIzbJ1PqkVF97/ON6qE/jiROkEUdUruCps3vBEKEE0/tEFcuThN14NAF4linT7CGv2ltXIKW7ENeELioLPqvysNK3AicRC8CK4Ms3IYiwwOLpkvHLosvYU8k53XZnxJKQpGtPMG25XwDnomecyPz45Ik2Xnz/vQOwJfqOj/Qs0jIRHdZwXIa9/+TQj4KDW7qnVkgNQf1HTuAKMigYWs8g7GWjaD7XEGdTaxu141/yA1NYe9+7yTiX68OIf3p2/Knwhk7eoNNi+uOhRs9MGRp8VOB2IBqaYgXqlfggCg717BuoXIs6bfkbXqxtyFIITNZ6VcfkYmVtPac6fDf2jUruA30NK8nFOXTKHKF4xotCKplPSQsXMeiawfu0h1lBkPjFfmArOdFG6EDiXJo44NzkqLoKpo8kneNUOdWSVdh0gAxFeH4b0D5Kj2ca+EL5SL6i9YtcrqR+9eUzA7Vk1CyI2zmICLnm8+mI+o4yd8XcfdHEyZjOU1ohqJcxUPXl9KH/4VXd2KW6CCjBIIcsNHOFmAlCYgoUuChMwyVtz2t9g4yXDL/LrZfDnfMQuy1jmeFnUhl9vvn1xliAavHImNSRZTPzEL+JzyqgQoANvdYpPQlsgYwoOpvaA4uXzSFp9hPwAdEPsCzwXB4m3eDRRNNIbdv8bC9DdbSyql4cxH/6JKAJvsbxrafoiSxdR9uKarGAu4IMJ371AmRqb3yq+uwfpIZvBYeNjUTYj+njWVGgsqEptRgrYUm/88qMWid16Pxjol04jyzLmQ3f9iiAsWecmWPD/if2KE3EWqJ+ZLkijhTMRGSLMItPl0zdk8nyTCHk7SJ9aV2HP2QE25BzYILTrmcSlVQYOiETmqAnD8+MI3bRn0yAO37Pgmm9I/MmEWEdLRO4iD8gJOK5KSuH0fmQjwcl4qY1AhkQ4V3GZ1jUZRPx+a2+pTAi9C60ySb66X43rmfF3HkXwZmmVPlVWlUSk927iaV3Lxt8uCbaKwLORRUG1FTNvsgn32IiZdxiZ9poM4eiOIIXt0jHzL9/lf/NdhVwLvOS3bc0w6nLVaRVFhdSiuK2NjETodx9C9qqApQm2AApgCvfVu7iAixx0QWy3+4OC3o+F7L7VSHrFmb+KF46dCSmnvRCf7BIKtFnhHk4bzvjbs1khv2Nd9nwlh4i8of6afznBKu+05CE7GTWs7K92bRJJHHzRj2XPcRgud/p4CfQHh0K2+DoTkf/kgRXUMugwScrX6/G97SBajXSKGgvXaVSWgYe/rlGpuOiyMrom/5z/1IFmuzm8USFUO0LIoaFDNOM7YXU/aLnBF6ydFGqeqoQxciVX6vqBqbyO2JGNPC+z3ezTRe+XYUEnT4JdNUNKsEHbIBTVJ5xiNDc4OmXB+nFGRyFl1JcKaLN5WD7W0crkB6P2o2vz3msFvgycnKpDXGG50TlFnrPquweQ1xeWuEVvywMqfqNzI5Q8I+Ya4tqJs43+OfUnfG6GBIsauEsZPzUP9EZp+ayLyWN5Q4FPP1/rA9z2urs89KuxugzAy1faELJH72gRgq5wclmocGgYZk2v7p/3yJzAJ9aX3SUV8qp/VnzAmWV5WI920YOUBiQaZ/GW3k6g2iGaXSG4A5jUDirXdHnpWLyiBT1GuCxLWYTr3c+AdiKrDvG7iFW1c/Kp2my/tH9WbBdBZkgWol/Qrnfv830+yNih7WEG+zp/MlnuKKeePEUFTuRs4lhzk9X9i9MqIeU/mOYOMFvHoywKbJAIYDM6Zgf2EWwGXo74lrKQgy3eSEds4YJTPfx1OmR97r7ub5hU91yNzU7Gt4F5mpxZUfnWerBIe2I4bDba1kog4Iyj/FMdrB1uOvwgAtY/rF8iW+RqqMUdplu9JRxROKlXJqFgSmMDgwexK1BddtP9kWjUr8eOV9N3Hqo46e3s6KsEWPJ+/lg+svDv3Y4/nm2bZibpd5R5RBqLH9ZtB62KRa+Tmlb/xgaRs2+CVl+ssXcyIuhE+YoQ4Hao4oLO9hvawZAfBhf2MkBjiK0P/lfX1ud34teur9g41MfrowIUZhNft8BTE7/TodHjeMCIBEA0Mcfq0zLcOxqpjg1zGFQnQRWZ07J/m7zihtbq037g//iwOV9dw7GmqA1I67rG/VB9QaF7YxilfDisEE+KLWRjq6IPLr4MoX6438KyD+Zyd75K5GX2Hm3MSsd7MGjamDzT8K4JZuMfZ6ZUQ7UJPvxHwb+bVQ+uLh7WkWnsD2eLrbl5kxRoq8XjTc7VKlvYBCsBgcSU0Qi7m9bzrRTrRPT0+xzJ3y4n1P4OimCoPkfbfaj2Ifp6/no8KokB7pQxcKcMq8RMQRz32FrxcN8j1nxp4ef1DHYAlMAwCKhW8xsHpQnbXNgPvQ1+6L4urgTbkiZviVM3F8+8rokjKcY9mUnedYxXcvWX7gYcrcqc/2IbpTEm+/k8nemXsy/sly63azpnVBYkOZEMb17K7PjhND/OMFOK+/2o9jAtMqijPlKvM9Cf0u1/WqGU8LSXYTrGWfU2ygkjVXAoUKF/liJ6TTNRQbZR6UeurVkwWrtHqGCFl30OXnbBqiXi64JccWvu78aRePjUcTAGkDVpcyREnkYERgKcsiBQwzee00P6Rk6JkY8UjCU14YU5PQ8wBTq3HYrz+o8Y8i3HRZVNemEdYU8kjHPKpx6zHs5gxWVzd2Je5MKaLd7Oo8jXEaW+U1MDaq/0OiA947ETwOQKQry8KotL3/A3Zlw27PGM/7jw/n4SDCYQDaYt9UfvR30zVqexoftC/+skHTExAroewldMN0P9p3ien2JygH/pneAclvfRfc3wb1RGKFmrGg1mozu+txi3Sq/E5tb2MfduO2Oh8R31iQYpdiT2ze7o6oufCVaeRNpGTxwOpQJ80BdLJ9c8Idv1ESCo28YJlEA+6AV++5hzVjt+TjF+HboCgAYekewT1/M6jc+uEnsfM1hvKxLiZ/TrUfHIIXS1dzWoVceR/KGYud1Uny9BTR/6JQ77sV4UND1nUDObwdwuCnZ7xZ0i4SK+2dC7/9dj2h7a2MZllGohC0imySadb5+syX90eith5BfQ0WGdGhzI3paI5s8NXF1h39BXLj57nZffeKA2EnoCTjQIF59e24s0ztn2YC/7dgj3rz+6rO1tt6hbC5aQAAK1XygZGZcHAoQobNKygzgOxmvAn3WQP28qo2gZzIhH78jgO/C3dHj9g5W/PnmgKI7yUA7ZHVmLmGXO+BqyyDRYoByg4HZp8zO08Y1bqq25kIkMC3Qe8dYugJ7512O8MCevm112F5OY4jrzKiG6sWCfnwn2J9LZSr8xaJEpDisd1wOFHtk/s+1vNWLqdEYkdw22xDzaM0UC4qA09pe52yNfk/r99T6SPT85oYVx7pinrAwSfpf3Bx/Sn/0iXp88TgNriUPmCaY6lxggcaGhXut+yAeksg1YLWv1Vycpn49FpB+7PSB+SdCE5oKnadofgG1vEjkdtKi4er4hJt25kvXsE3b8vQf3PY73gwTTP6wQ2aZ1mjVL9Pw6BPbjmpwsePlbSENjO/BEmZA6i/FibIz4bICBPjZW0AsMviICpge0qTyZAkRAUCsJRvM1PUoT+hSvg0NeQ5LaMb7QdNJc88+YizUCp3iZqHkPTEaCR/LHWfO3XpfErDHPvWWstZB6DlI8+T3vmr3FkjrfYKvrs8B17sNCIkZ6jfCvhiulXW1RXO8KJdDwhwN/mtd3yt9DQBQk35BhPT7uy/ZdK5jB0JTgvYDBvIhuz5FKhQEdXs9qfjEEzEgEpMAWuFniKg+ZRCkwu/+9zc8GTEVarml/Kyh0UWs3IeOCypKThgMZWeMmFIJkgFan8L2Uh+De6fQGOTCAjCN1ybAzD1wZxYCdIBhVPAW1usOV+S642ER6JPJi3Z905S+KU+SYi71ElC744OPz15DFn9rknUZT0y9sUPICtHF8x2RZVQ5yUR0oAf5yY49VhD31QLK1dcWnxxAjzK84ObGyv8zW4nrspkNLAGuRR4SvRbQBl2EwTtuzl9Y/F3sav5PkoARXm+7F95mDcdAqtW8UNO6YkhFawQkd1Y1Qa3G8+tvum13NJvPDA6uRZCpnD+kt/vOtM29Q0bbdhb/emSkbn2Nk+nZZOzT1pzUNR0dWrxGiagM9i1VavN0Jl4O/Horh0309PbmYq2jXfgTgGpxQgu3HLC0A0OTWGhafYC3AlMdoapf2yhzOAvNyUPfiIOL47C4XB0Gm6JxWvnB4BgqtIikXKaF8hqza/+Zwt9TOOB8x6OnPWhJ9aOZ/cnyp3/AlPtAOyFCBgWG2CN7HrDHZF5UtI1mn6pFg8O9tsT5QOrB+pj2hg3rUgLoSn9fTwdhBpMCcL+qK3wW/jMUCMKiQQesBFPDEjT4u8Dyv501xNOqYgmvr1SpeYCjnqPUBlJzZYsHRuR2l+B80bf/uRdp4fbPYycNu0nY8urfbdntcL+CGUcmTXt5dfuQ6Zhr2pDlQLGLD8imR7m3lLLMzwORcBNGgtyf9U9ffhnbAoK8YOQmcpduJBri2xnHZN46pkGvQBSPUhPkgCADgZLQicFwu9K6Yas/E6wtoDTyxFDTFBv7HfAQgONJxBw2AxAW7yV82qXxo2lH+4AGBm8Dc4LCjRV0cnPc9oCCJjNbv+m7CPMDNgPVoriN+ROAKltDOAXODzf10bziDVGfU9NHjob5O1LZnc4mBTbOiuJRx/9k4iyR50wKlYdCZRavIj39oDfJqAPosoh5R/lA7ozffISxPYPAlDeWoHlk37a0RVM9RCkc9e7921FAMUVz4ares/4ex99h2HIuVRL/mzulEM6T33kozem9E0X/949apqjzZt3utN6ha6Q4lbgNEAAHAVh/UISaLys1nbsPvdowgK1DgSyWcuqVcLGO2euRr22Q+oBvRmHRuONo6reEopVJQXYzouzb+ihCL8vBUrAaZX9kDwEQ7CaLAOEbCIEfpz/qyMSNe5NIiycQeb1fR3QkopeSXPsrbQ5WeS7JuneMbK5CejGaZ3cbWRD6tZHjnenu99E0JMIgV3YbCaf9U9saLhhBK7nhCJztU52BN/HIo2tQmpMjt5Lcv1cE6YzCPB6apSMbpQm5HUHNksKXhwqu3cq4y5IwlS5kzcuTruinhWqSOmYwwrmZzoDYKH8WGWi6yDRo2CzOPfmcUvaqbCXHvhPPTEpJdmujNb4/pIVXR8N7Szjg/5+N3PmSkMQbxlsyjp7B/uSCyrMwqh1LMEDxr7Vecf7Tr53bG7SkyIZKe26E0q8Zr3+qkZ+2UF3JkHY9GzwYCopGtKdazLHJQE7HxbqsgXYw1IuZrcfaK1ysOlW6nOxAtKysZVE87uETwdYCoPNnO6IaLKeeN8Cv67VHlCrCYQOH6zRw1pXxL+Wt+0Z7vzXulzYpYxzvoEwH8wNSTUj+5lfV0gD2iAJ4SEUtzO+72Pt1itfyQFq3NUOdA67+YScqv0w54nmJRbOBr/lyt9q87St9nyJVi83ZtIOzY+NBqOO5stbIsDqiyc19Pw78Z09PgJpzVUxzmz4Z4+vRMJz+USwzE25Rdd5sxhwj3SJNkutfgFG0V9Eveq4FPCZfBdwuXJvqX1f1WUYEuFZRAYoSJQJskfGaJAgfSNpyn4Rk8FFU4axk9xHOFpLY4NctUwj88WKV3bgfofIBy/skoo6MLW/EiVH0uuGym3ZSzf+dcxmrkOzjyb27SvZ+xrEcJU37rnP7LaMgqXb9M/Lw5WoUhwAZadXiuwy59s2lswigztr9W3wXVbpIxJanCoCZQBlftWhYM6lAwx8zjijvjzUpg7+GCTr10JdMmP44t73GFokQQjYkbV7vdX9oCFkTVfK9yOvb1rKIHcbvyTBFs6iGX3uNR3n46rMLls0nFW5aIdL2+JRpMT7WELEO5fb4ARpYTELTIQGI2Kc1H87lxYUVD+DcvOKsUgqL+u+V+xbgjWwWCry4obqihDQ8/wednSv7qAkmykg3vn2tWr1M9eVxnJvJnCiS/p4FTXEjRO1+lzp4WYruBaItG8QYDO1ocLhN3MzUVb6wIhoikZUd+ZdXrg47Tk/vwbfHtaECKfGFzTXyEYWT9si+ufNGYHDbGp3xCa++HIP4yzlp17nKUGrJQ0Dv5za3sOlN1huKwp/bywyK034/2SWQx5AfnSzJ0TT5vvOC2HGoAoVgUsszYY29vcJinA7HIDSEK68P3zPZOz+s3PmBLzlYob38j5qezZGadwcqL7+5pWL9UC6fSMw2PorHEyNkJru0MdLaCumLhKNl2Cd3+ikdQvOFEQdNy47osVKmdABo1+nP75bx/IRFZzXXsz+b5aNeP8ZbCUSujUvkWXxg/4zWqOgvbZG9FHtQm2sDNtowD6i4oTLlN8K8snYzeVyxsHqlIWWX6ypHtwXGKqCoGIxOvlCkm2cQY8C1fDhe9vn76/JlNzcv4beHK941wBdtKPxG10dcaPp7OK6PaP3XOEOszHwjFh8r9mO/PR3lzdrb/7o9Ky99cCyMCTll4a32eBuEgfKlnXTOuE5+D3iFaKRDKuFZMpbS491a0CxHhN63zfB2VcifZ1JJ29c0IIo9g6lDaOfu+/xkqKy9eu4+RaoWM5YGruXRNvoVR/Hgq1F+KCo3mmbpNF1sQHZ6agCZyeTLj4E1TheJ52xBJsOWhPHpbhJiKdX5owFvhMIetpChLqPfZJoV0y7cyrgz231rMzGZutjkieI8x7NY9/2KcLD/Qx+A05GeQqUXCeERv5GCNF/BvApJdG+LdV67mazpZva/tZxoJKD5jdIBDpvsO8YS+9Z41Ipd6g0oJA/ofx6vijne5QgUa9pgO6kq1jij+TPyv6huQzZGfg8T4V20/rl6Pn+aJsribmLhGZ8a6vh+a51BjN+frpNxHC14qxHxZePrz/mYIWyC3UDbPp8ZZijIuIfHaQXRm3fC0fP2a2QMo2rNa/JmGG6tLqeDg/rL5NS/fvA7gSN7Un8R88kirs5sw1sqz5+712kEOsRVxYRuwFUWvWVLzKKEea0AH4XeKhdfzblXcHDyO8gB6ZenXJy50J5vaPFQHuQRTjnzysabbGaclpLS5OqaaMIN+n1saZ3a7k4yklkvinV2LA7/Hkl3PGIiToLZIEo9+wCtOvL9RCIseSonzhwMe7Ddt0hmC3qwvxRtsQHb5cBg0PmT/Vx2uy4RsKoQH5PhMqfz2NSwv0Uf1YCcjEMVZM3JN6EXEUQDSfvMaHU3y02+rQAFRsndasENwBl9W92R45lvgpCtHcvZ9MZ0TpvU5DuKFA50CrXmdgnziTRbsy/0W5HBzSd54AZ3Mdj1/5bRbL2TsveMNXU+w4xO2OcxalZ/6CGuRjqb2Dep5vqSO56M3VY1M0NV1tf2HhfSI5oVaDbFe5+twql+U9iaQibP/i2WPDru7qXg6+SGIEsDfVH7kygm+qhVb/umDhI6o+MdSBRu+uNn6i12C1q3Z2LyEKPjwaQT3RbkheLBas/KY5Yu6/RD/M6oHMBrmbS7EHGhk/k2/Yu8FYEmzitzsdYXUuN2n+d61jJ61g9DGSC8zDSZzkRNaExploHt1Qf+PmNEcRULLCIXLV1GEzy2QN088qi4tvfG3zlFz+KPWU8NOLf+I6/lhBTVeSrKs650nGmktU4q09h3ENaa62D7osMuVDkmLaL7mVxKuWiPV3AOmW3QNh/RzHRlD8EX4X9Yu+65AnYvRjgxIKoYX6ivgtR7PIMj/0q1Y9+EHdYZac0C1/XI+AzExedY+b0hC8s7gTz5MAk+m2vw3WzXE4BBl+CvL8XVJFxLbZnFdPdBwi4CmGgVBEAd09b8Nr1/XmQhVBdhC3Pu0Iacga/daLPBEer8ZwMEbRsWayTxMbyyygsaTW9RAFyMbMg8q/+qJZdc6HwPPlTYvJK/AWbp0wYN3MXdWRAyhd+GCSA83eZHeL8a9iDDaw566JK+EWN3wRi/G0m/2FhJl2OWRaPEVaUoJ5EUYflhfyD5fIIDx9oS/9UL3qeRphxyq4SN6X6T5tv9Sb6w3T4jJb2/APibeVfH4p35RsCXiWHvzoVSHQDPvt+CXErht96n6SG+YsF5rlFxdhryAkI8u9e/s3o+Hhf7abIBwdyiqbt5BY2cFA50/iYEmpUIl9d+ZhLeNQugwxdpf3bbokCGA//44fTqkERoPiSXt/RYFiT1ocw86e1r3xRWNnMYwXZISa6gusIsMTZjPbo6KafF2BFcQzmI5AlXRz9QAiPhAs9yX2N/1DFylNJs/FFyvEm/6r8o6s/ZKlTTAsqwJkZjrg4Zue87R1c6xvkO9v6xDd0TbNMh+Wuu00GysExPykE80azDfAQqMV/lIGe6ICcM5wtmlPjaZ+iM2rqTHqKY0bLqDbCjIFn57/kqkWqn6MldzPBm3BdPRRW8p6jeysFmGwOKSHwl8Zu2odA954Vt6hE4t9ZSvBStaSbM/h5JQ1KXoN5rdJtNbjaSDCsIfhw/r5Y/I7ECI77P/zgCwNl2KxWt/FZDvqxZbNr9u/4PfWPgZHRQI0PfuN1bDsytQxj2W9wnTH3Y/nfJeAwPBfP2LXF6M79oflvjW0r2nHDh4hQThDi49xPvnQOzy2wvn/zqBCabhj+KEO3yTLhecXbrBPGnWml2G+zanrUa1vGGTCGPW3P5adDfpbqeFeS1Rh1M2FDgTEo4xpoBGu+TubxMCdA1NWjK/1H07I523zTkEATQ/Y3/FoO/9bjB243JrBj/nz0qYMT84kwT5A6BwUvTrtnM2V4oUKlyR0YNes/PTFLUx482gQb+f8VXCkgeISwh2JcWgOyiN1OGFWJbLfjxtpXh9FqBvpfPzlzbYVeV+q2LFCCqzTEnH2vvCDgscGp9+yVKKaBvm2k35ukkmWEC2NEupOP0uxfHxUuonf5bOpwsviqB8w5y+rWNBV3ya/CufV5h7GFGCEKF+G/3OXrE0uzWxPiNkiP90U3FlnDsQYpxxBdQuDEoqTlQSd0x6nwCxxB/umlxEtEq9f7NJ9aqdH+VmC/RfLFS/WEfwiwu/yG/g0SSDTn//4hW48mKX1PCAiFaOrnUhTbhPVmuycqTerTjNpJIj3Oix7FyOW5bXDy7JoGmlRevgJQMY4QSx3KZhmwmU8cIMbDvotZLlKf9fVxi71FmbtT6kvr1VTY7Ebv6VRTz4goax5KBSVHQw+qtSuZmBtfOZX0UMZHXmt3NzCnr6+DObWwODl3P7oCIa2sJH8XloHaKDiAP2o0oNb0rz6Kr0CTSrTPeRF1cnghNEDKQdRgyXbn+d/4Z3TybB3Hpu0iTBfTW4xNtN4DXpuqlBb65TLKsS+AXx+OamcEfah9fFLy5OLe+EJ28swn6Je7L3SIp2juddi+u3v6uHzd3MX3uQQXoC8cQvLHff94y3/rnvFOrX3f5P7b1VyujFCgduWifo8qAt/MUdbzOeK/oMk7Zy4k9zts6TZ8DhKEkB/77hVozgxpA3Xtc86vF0Bcb8OzbCPOUH26WKGxDLMzbNbAM7nq4vBs2eHigrZj4vSuuk8uncr2tETWuTCpIemr9WVDxwA+TGllrT8COEkEJrvfTP0zsfuAjuC+HlcX1PsZL/juyrtEHPGNKo1LaMEN6yz6/vNEDnSxHxtL7jbn5ye30mFQJ0W8yOeIvcxfvmIOITOd327atkMMeuH4+FArOHmXM0duPb6uH/rnc/6O62b8rAWkskIhZ6+7nQ10psMhSfLvrnu4MesHFwdSZMni4zNEMDg3mv0DXLfUnW0w0Gna2lgvYRKLpjeM0nRQgXYoFs8T9Vo8IXzOO10UHk2kL1UX53E8tuNpYcS4r2B0yrpc2womPZFvFZ31tHJlpJaB9BeDp0V3oqtog4TMAPW5+nc3u23y4UQB4lTzssWULwdBuWT5v1OTkc/VcG/F26pzBeuDcG78678SGvsllIinEQazfzwJMP1j6nbxZWcP0bYWX8Rt9c14WJaUWwISVvh/wVozDyq79YkBaByf+V5bwukwP8sayDMvbZ/beKktuXUCCLxOJGflyAIx0A29ORyYhb7fZ+ey1zIunacReSn0WqfLzoUv/Igz4H95ih/k91OYj/KwogikIrnQt1TPKwdz9sIdzMaf8LaUG1aQ8R52cQvW9x3Bwc7qFvGtFosAJEguw2LL/Q/WunVL5lS8n+tmN5mBg8R570tm/2quDVxmRmZovVmAmN8VdvOJd+UQMznB818ffX88993m8/hjTfUvVu0Drop2L5fa8J7w2vXKVy8adwrGxZmWnySLHwZ3Q+n8CPX09fcRUXkmE239OVrZ5LcR9yUJEww3yJNZIOW6Y0j8aRRvxke78VErc9D1nRHiQjoofmBbj5zQmUGe08zI+7crAdcgThrG1bV2xKbtfVv/YGsv+z6ERcw9cl97QXoWcOzMCRKh+0hd18/Jd82Sw90A1hcRpe14/zRl/pr1io3IAAJCBhUw+dBl+7N1yNf+aIUcwD6Lpdfo9oOb4oHfYVEBWq9jGWHt6mRf/OfaWf9D/5qPZLGqa0AeEIiaAcCHQlhpNq4B4j9vcMPX2UPpX6rX3A+Td0KnVAOyOO17FMzizEF3XMPB/n6rcKBKLtOrBT/L3l3YgO5yLJARuUFiYVORVtrFupfVR/fdFPVZfN7P8htoJyf8fn7PJ5I//EqQoUv7rz/MnsBXQjM/ADfXlHPkyVisOKCXJt/FM8c81bl4jJkTWAXk/SBf8+ARbw3l9w8bK9414LVKZ2M7wIzn2irOUothRjj3ehMX3BVNcjKecdtKTz713vJC1ibAOSWav1SGWAuoYM7k3vbYacwKY324oTSlx+dBCMJ+JSuTzZ/AfJO/PinkOjUFxbvANCsoAWgTHWJ/8fY3BF98MNfSqRM290zgz9jlD9QvDe60+uCChlW2EtVhAbqdWS9FkFCCPYE8qDCFBrGO5ippTt+MbvVArY+7O/+fFbVSD5uKpz8V86EKEJxphjYX3F5zTYJ4PcXHt4pGtgedSyH7i12f4k3awCYnJmlt0mq62RPZNH1m73RVWS5fTbhcCU+xTgySiCqKTQK8kTEtCT3muM2x6su2aJKX8qkqXsAUOBUrntDBUIfBqy/0u9k5Xybx/3fH+Ix5P/42+Eki9E6BkBDvb5mJUb7j/9W6xKH3vigHQ762H6qz/xQATSSLDoNgDfGgc5XaSvHunzo90GIqJ0oM2DhUAfeA38/p9ph/lnfy/O1554p9wzF3l5a5JgRniTo4PwFAM54vsLesIevL89YnS2sIWE9QO07eNrQY/ONSnjPi7LO4td8rIb23nMB4v2yBa0x0t5t8xTAl/8JjZK3MGLNXIPiu/rM33XqYeOuez/uQdfTYJVkkD8W6tar34BYfhHQyMyT0Y6FhCvFQpC40b/hme3TZEA4VC+TIU7vysD1A9GikZODuPROj5N0xgz3Qg8wi199jt1SJ4u+KvjBlbxv760LlgJkCDPhojcyJUdpy2lXo8x/dRhdUrfTo/1jUHixlLUujKkewlil0qRK/hIYX1/kQ5xbTjFrkSE8XjSw8e9T0Fpfg2S+oDX4injiy/RXm8WQmoabhYIdhZIqN/81qNV9+l7PAYmQO0j0uf7B/54SfGPn1jaJ97ur+/hMuxq5PswczOjB8YxSTMMn+RW+9ZEtlxkClBSxLgFUM/09Kb82NG+NemYsf+ZSHObSlUi6G9OmoYgOQxeN/Fgdulyw29xLYXQuWaveg60qTd1JivRFN+1YkAlq+slxlnTbcvat+o7o0w+6wKUuVxRNdgaSD0sb/rGvNo/GJjIjXfQxs7NyVVIrFAE+J3x5sNA+ak06O8KJYYWbHBxrh30/RjHz/vh/qXptmN5pecdbfDHsryPGxzeZ/Z/fuYiI2jjvZv3ZP7Pz3zGC6SWEAno/MI6IuZDfiHDTQnjpO8Y/8X2Xm7OBiur6uxlIydD7MSLdKs+J5mnz9qW+adNtz6vyA7HCrTR3k6nkn2BvW/TTYsUzeVLFqW7fSS4IgsiEnCJ2S/iJJ7wyeQSKgYlrxieUeFqFRcSJBvyzuYIcTi5HllLf87oJgwBswn3GyqCHDOIN1oqZe83bQDaiMhpPxGQhSALjJCSrINYoXJ7Vhh6Iw/ioNFV3N2jaa9rlpaw3HTQEnPEoIXdKOb7TBycoEWmJlifqkcniXP1cZYv04SUQH9icX3bs4wVJge7eVZMwU9dnzybjXVHFkRDKxy3uf2mkN42j7vZPFJP6/BSr7RSd9vj8GISoA8gjxcWCp4/qPxv9Yu7g/1Ej+EThivIDLrK+tsvttyTQS9cJwjrtViGVTA/N1RZmSaaleHRXOg1T0jNgCkRJUp7pkU18n7bnpdmYJAXDwbU36janNSnyC2eVuzvkbr9pP9B5uAF88Hz6ZzLMAjQ0xIXb3ydQlnR480eCaHbZgorKcBuPWI1o+HEOnAc8TYu00/irF1QDuk2A8xQL9d8vUPpCfnvI6bQ1gnj3W2Zj/z64mPaTQIEoXztQ7luWUEh/i2b/VjidY7p87daoHVTbjAxk6r1zxmRtvE3Nk1KBCuOwouTbMOVb30az4WMdOUwIqdXMoXf1WBLl06CR1yvMNVMtBWM68/Jl17LbQunjzCwhhvQeuLgFRA0j5/EnDmqkVQkL3q7RQkbuGQgfxJgwjembLdVTmaoFwqjWhIdxQyUaSwTzqf9ANPcVM5kzqqSiCx/nN3+emgwQXcJCGTQCk+R11uoVEMAUSUxS9CTxWbhfWRnwOzsNprefb4YAS/8B22zh+vZ5643XMD55fnJjSq8kk8b/KeokO/zqGHAgyrjin/qSP+VQfHBEwfoiSL5dbg3Lv96V86WbJ5aIQLfqqSJpezD2j7DCq5kGzbZz56R8GklAs+Nz+4OIgI4w7Hmdzgw1hHBy/j2HTkW6Y3bRRNz06ISAj0aJE9w3QZaRgv93tpgzeKHA4GovDUZI6eJfkCMVLAAmhAHGwCyXfA4ZqxbX9y6HeoNQF58SzdhpsEN/MkSGEE10FlD0sGbvAZIKq/IoqMSQLaZpjDR4WKtGqq8O8NfGNqalN1MiZJAdBwBWQzX+V0ZX7ZKziVAxC5Q9yGACsz9RvUhkWRFviAi2/zm4Ds5pNEtmyxhjjFK/toZAVnZ6wMmXZP1E9SXGy+VomaoDR/77fEiw/bONXqLO3vv9O1uqchlU8/bgqmBbs5j0EUkwGFmxrBAiuUm00BZrLIrw0635Qn2bAu6hLYOIo8XgeRu2s6Cslkhmrfl446TsLq5XzQj/63Z0Icjkh1Sg0Vwbjrso6/PgC5uRgJ9KP2HgQpBsde2+isfEbhSST5QBkYdRwhjn/7FPG8WsxOphVnTZ1HO4AQ6DVjp6QaWFHQmL+Vtmedb+vBquQFLgzQ1vPDVczPeJ3i26nDPlbk8XJnfos1eujyLGGljYYNmYCxTb6xQr/SUj1Sv24Gwxe1QH0QQQ/D4PB9p6OagcsRT3Td9Hh1PjfxwMZ0Usm8Y37rI+roQ29fgeYAfz89tJxvHiWjUJWv4gz7U/cZG4OiR6FA9OF3emLJTqvS+l09G3K+GPFDu6v3SS6S5Nw7mX5uC3/Dgca1fPt7XWrtPyq8I+30T/7E4r8Sj55vtfhV0M33f9JCwgK8A2VWQ4s6BNAUekP7G1czDAZcTdZs5C8ystEp/9HbQxz5LPFNe7ks0i35cICXtA2tFlYlD2UWSiiql4HHA0EAt4JNs56kmWS/pUI/jGfbv6IL2GnBvetmBaDtO2srNEuml0DIMsfFk7R5rUmVLnq9Y/KKuUlEoBlNDxm6fFSSm7FoT8Gd5DT7IYoIj9m4tM/2l1QZJ24YoxPzqwmx6yB72V/T9plKFNV7bh2o/J6T9hK80uRzNInPTIiB5Uwkzm09BV/veb56i5RXG7GR9aBxvYIg1+sPLJqPQpj4enaiPSEMbMnPAml49PFsW5IVGVmstFDIRsI+6/liw0sMZ9z4b4mv7aGNeDQgAEZ8leitPl5zDCtrybZDBBSF5iG82TjPN8slHoaODwPgse//dmAcEztMzu22K+2kO4AOwl7JCM7Aa9U8mJnUs8e/40EKDrlf3c3cSfzxX7JemQ2Xk/7y1PIXZ9k9NTHWjGSzuI0CFvOwht/tPB7aJtU2kOt7RFAbrbWX028qQyFr/2JhPHe9xhne9meiGniKtEz/Tl/1yCGMdsLK4n7zLOAgWLNssxe63OBVCNMUSQCytUwL6hZ2lkXdth3BDQdVq3V7CU8OswA2ZvejzPrBXxvlMzoi8hyX5qoMMtmiQV7SLpVfc5vZzm1uhZ6Rv9zf/ovgHqm0x+QqKF/MrnjP8uyp4X1+X5uSgFcNe+6BCj3Z9hwmE39kKuvQ+LPoNdBAJF+fWRn1rrVRuZCTYdLfnMkh84sSPggQxhNzhdxKphzQ8FBKxjqnvVO81Sntrlg1ti/DnAbFA/4/XzJIade88M6b6yF7UL62JnY0IeerXG9gvevDLUUzt2JkkMTUihgpsB2AeBvl6byC1fHRllNim8ryhC/JZCfSBk+G6r/MNjReKVVknXWxYyqkTZswHwZAMSgqKNVqANsCZ6o8v8Cu7Ge2Xu+oPitGLhFNWfza8XvwTy2VURgJY/sv3Yu09803yuyIOPISeH5RoUAPInvuNI4MpgiLOkzQWDqkA6qdv/rfQ1X2MmidMeMKb6rCTiBpPbYrj9i/y14cvHNbh5gRo7+2ZCrsBxKDvj9zUEOF0lC/D/gqDD362kmLJEiCTXoDvf273cx/D/rUBjivSaWKPhg/+vr9xbgRbM9W/vr19Xq0vmhOz85A/PXBquHHgi3DbQJcpnrhxIOteJ7ejg6FxDDtyuwRr6M/gdEGn82c/2HL5V3XDKOxiQXwZzzSCxiPdS4B+13iVNxOkeoEcSJj90QNyL9aqyZmIBtRATMQtwZsIskb/o2FpAum6QUgyOb3RoblO+NXB0VrC3i82v4hDAzfp9eREM2jBL0U+f5gQyFd5vgC/ipXKBMDpiL0rHLUtdLAjJ4/XepoBqM/F3EfwQV+I5TB3c/IPXe9q7cTdYUpbLpXDcqdj1X9SmfLRspx5vA1o0VItaJ/chrFuYecM/ODXl5zRWhlguWMOsbfqVQAQ5+MB73+ssTkZX7wDECzQVYyk8LtOtZNBVzHwEhTVffqstoMfhl1jorF+Nj66UY/D/1O5xBiO+B3clE9XZ165BsdJLj2me/PUEqGdTgqg63nu0Ar4l3cazOkGja9XRyW9YdGC8o+DIfWN3Q41OG/m1BzshTwoAV3yi9ONCFNTUMUfyLdx8gED6GVBN/Y+JCtz7wl0067riUmPlTKjyH7fqEcRwpf8Jm4kW8aDoNkkdrMi7HvfImGb4N0/7Joxd2vM8F7vo/wg2A11J9OWf2spw9Iht8Z6usRtKFua/KsvP03+h5bzl6sY//RQunmIVIqedaCDOIQvgwdVSAIN0/0zpxx0gMe3dLzWXk2CIFxPyVZbJRFOM44cg7JP2veMMpjIo+lelnGvkEshcYu28s2Xy+t0yEu1kNmohs342gqN5n66N7Q/zLNJRamLo0gBl+OcsXyyfFx/6Dj7nDikZyepvG/ZG8OnJ7QGKOKA5ouYTTJI+kU/HNkpgi93ykJrKGQN8Rowv6uALZcrdQplhtWBwdEh2/r5++btv5DyuiYeiXM/qsIfpOy8Ht+e/mbF3V7kRmA37AkSvXj7EIE2s8+G4Ye3b89+VIP1RK77Fs2vY88iq5MbB3xgXb6ZvGJ3Mk/jnMoND2oqws6euoyG9mn6/I20cH6nMaQanNGT+yeXzdr6WJNC6xvEfj6AuTKhubPkl3h22WZ0FQefvga4ziD/e5c4IyWZ/NszzhYINd88hwoon6WuIn0s3Zmbf+LU9HciGlA43KQcYKfjf2EAgfb/s8m3lQPFJv0/ekuIseLMNPs14zIOdG6JYrSjWwnMKemfrBEUxpRQy1GT8XybqC/BkTHmEL4oiIeezGTn5O0z7cpvRu7bcEflcA1BB0AsnhGCxJoVfuBitDBggsK42Q9QPczS6cM/mvd4Zr5FK0K1hvkiztOCQ1MazP578Vo2JpNABTuKPnif/laZ3Z98BjAs+csH5Jvf0Fegcpu7ORI66goj7ga8ng3+VCKKD4dP86CHf8coUgYUO4LsdKxt+iLmvzubsoy6a6n1HRD6MaYRGb69u2q/fLJWm/Z5guip5utGC6UguykzqszBiw4qV/DXGSaAaCVR66wKLbGiozwlbsEeTX9xnmRMfNhLIpQ3pmmOZxN6MXrQCLNVlk3C5NWaY7Zas4gskxoRwxb4tgiYTlmpYycvEgdvPqaZfouxVHr7XrDci1kOHlNjx4nXahxftjsF4noRo6inq/8mCTkpQCwo/VZ43lyPoCDQoz2+fdUExNxK9gjescy4f3rtTvLXRgOEMo3jQZ7cr04Ui19hxiqREYSEB3x2N/u/ra1Gv0ims2Jz2PgwC3jl8bRBhqgepZzLazyimhKr4YyKjieISz/KxL/QVJ2tVRY7CFZfzA312atawhjZr5t3vJybMwDCmd6ndLPTfaRgOCzCB/VIOslcay8CltIq7ffLuW4EBERl46d9Cx40nuSM3aaGwaaXGOqTRsuID7zxg2TefbgQ3n3IwSFhpP0miwIIshdSr92+mde4TSSmvD0fiAYCqFM7yY31uyrU0cAm3wsUE/NogGqp9I/tvpdJGv9bIQRactNJvzbd2qtSMiEpy9R7Hb23x3MbQ0XI1HS9l5MxlRwWcxN5sMo3EhYRA9eb2w4DHGhEjoUsvcZET7YeCrC2N2wp5TbFO8nqGPk7xc1WQ30k5ep5tJYzjJGzYAcCYhjVywtttCL4qYDmZOiu92DgseqSS04S/k3YFDcZeIsG7TaxM0ztVSBN0/58G9tF8YKv+Xq9WUotKFeSzOw3w9D+wdLSGRGYj91W9pei+4+nArnAccC/c8NuW/28uZOSpg+I//k9wjTwcMWYVL9BKe53eqEOTJL0VV8aJYjiiCA2dRqsUX+jXo3NkXEiXPqUARHKh33FZEQ/z4aKp80tP7fPKL23chyLkjOohT1vahLRw0Dx00hZuvFE+WWh8iw1DLkg+r57D6+Xut94zmDzX0zUhK+9+2oZMjpfAGnerrIugqnQ/y/2OHr+inQha8j9rhG77cv8nzWG+whYt7L6qa5H6YPw/POyjj+xU091W2Z5F3BZACzVwL39pAqEQQj1lCny9jc2aGENNxL5dPDBi5rvSDnvTXYHK7gaiBfGiz9MgI9qcuNzQ/OmUREu9ahbZe+jpHpeQG7fYU98/1FThtq8o5rsjAe/RoBxl4ethdR0eu1XK/YTORY3Y7im1ibZsNzeCxAewhElTqjE3c6/XVPyj9V1SNaqSHBMaDgqmvb/zBMAtANiziC6fSGJdwIm8M/kGvAGG197P/VDUs/oN5PdzNzPi9F0qAad3tcLqDwNNjxSYhiPfhDW4omoF1fFWzJLpjCD3dglYT4gplFN+I3KKnd/fs6o4yBJvie+cD2t3IJW8LcSoRouTQ9g0d3uBtZ1S9LNtzVSs8wdRL+mVEPgkbQPVtz/uzGmDiiJlN+H04KLmuyNkNL/GcOdfOtiHuEvHKxP5tfGokihodvr9H53y/Dr/ywsIBJ4BzpuqCVLe0gCbwu6RFs2V3Xw7rjrO4UVRI+fSOFHa/oyz1j0HxTaQuE7v1rGVl98ChRiAtLcNppfzVEi0rrHIfRKNp9LCAGxOB3pPnXV3iznZhVaUAstAwHjuzPsNEhMMFk50+vSqzKcrJ8F0lrynRmEx/5h3IRmxinbYKi9luwFHzP6omuOdpEbtpBzlQNMs4NOy3kNxj4Jb/fGf+jmavMYqUQyGX9jX23/noYK/+FQOfnp/67OE9KvVbFy68i9JIlR7BvPvplUfjMCM+s/z+aZeDn6+cfzM3KDsUaUWdjRPpbZQsokznY8D82eZWy7lJ/OJZpPoMfFn8xipx/wPZ728CThldpO4o03ev7ROPp0ym/s0TrE8ebj/p41AoyCbZaQ2w0N9Rh05zIqUg23CHrbG/PmmhjCQse+hNkvmyIFg1e0VUs/GTTPyAI/RJjYtjUc2sEr/9wOtYz+sa83VNs6vFVZ6M9J6f3hv5OCvvIHQ/6lhBolkjekl4FRCUSoBIgfcX6CkrE1WO42tPVDoe296eXYMIxyr6Hrxv7b0YnSzZhbziteM/thvnNpYY7LlyClYCKAbybPn48ZWfG3lwKmVfpj7MBBx+nPdR5YjewYTbLtm82mYnT9sx7Pzmo2r+zHIrJWG5vwH1SCOEWxDmqDMoA7S/OMV1YfuYdE1qX0ePuSyf/RHlh0hVlmZkdQOqQp+zvTcrYevQAACnAiciKeeMm/Oy+BHdXtmwolHph9wMwLRmZjDXfbQx0xhk7Ca7A1IGh9yyK8I2xhtPZitjJxcaY8HQtXL6LXM19rppabB7TFr+q4vl0+M9ewkQyYx82+2nn6yGT92Frn8e5T4Zn3/MQB9jOdOKkjCpcSNG8aWmtZb1dn0FTRLNmicfId6bfNY22WooQJmTvb/72frffNNBnUTJJ4QtrW35km779M02vBFfZ3x+HbQGylDSUtxKFzFItcTDmUc1lpksFCGnXaToD4sIo05ElJdj3cGHWbXilFVt4Ksey9jj795VGzDyKNP1xHgenXE2ns3ux3AzvAgD3GPCJonE9f/+QbJ4wWDsVEA+W3hyX2U+NHcVETj/4yHsuWit0HPr4kWeI6/Aq4x6z0fJC8CKhnvDKNND/CB4hSpMlypuWvLgyR7JHWvRziiqtcOqW/OwoaQEnEWK+qAIoio5sgEGJ9/q66AO4cY0TS7Cksfzrj4oEIiAu+DrgTXQKUOUoxeGp5e9Z1kEiY3sQBJKBNGY3W2+ffdqWyOREK7J0vW8C0OHrykWZ9MzWIQ9FvZvZzUGmnxrQ1Zy99hXoZVwMQBK4/9jgTZ9kmh/x6rCaXXjSr6MsLUTepBCszQFn8Hhh/8rVSwdN8u+TTA2WLTCgJnykAMct/p1ne+8PfBGUhX4S2UGK6aMOvKqE3S/4X6QVGVe0fvzWa9/kNHe472csi9ggVP2HA9Fw9uzTXxFaoFyBekhk5iHgv8rhYYcja4ofFG2uu4Oz57xmznoIqQlmQaegI7TDDwS/QNaQtaOaTENwN9mUqw9v01D20t5MJEsV+MKb+kDIWrsq6S+aXVMg3HpReV6vvNcmafscd7MiOwrdHHU9u6ltlb9Pwn40zyl94q24RFuX5P5lFyf8PgQY5mALP/IqA/3c/GDfbTSZRKbREPlmmQc/reiOb/+JfDr5E1P3+NfV4txd547SV/kTFRNY2At55vflHcpyB6EO98obAvrtmmdf1O87c8W1ZNCd3Iohekov46YvceU54M0el4AUKwJoKq//yC3p3On35gzUz9MoYFLAwsWYH/1MsDFw1Lf8LX39+cdloswDW/zXz+35K9B/aJNC2i/Q/tvSvSY8+Kz6yRhJgz7KNUhpfeaNaAUTC8wd6Pi7f00H2D5Z1+lgEHObMDjwovylHnpsjb/R0Am4ag2pQo5TP7ydQLgsKxsQRAcv+9jPKJ2eILUrT2+QXo4D8AjG2zo13+tHcIgrL3hRorQ/KbpgumiPt/aeGqfsy/ZsyihFlAFAVdg5d/rb/11/2/1Iv2fzL/v9jN9LR/n/mtdTSAPxhibs3C6ZvW448Mol4A8RTwELcTph/KlxYnBHtkHJ6ue+xtaYh9uEmpXnVCqhAEFLKJ9Y3JNHFuxEfZOoRDPFOEQzGBV79VYNwKit3DamYT2GcgbDa350WY+ffDOdi/KRIBZ6x//f+0bSag6j5s7oEUx5Fj9ZvRAMlfotzNtd+hx6+CMSJvxCVZBdPNNk3mklM7Q8gimC7DA/efqU9k9SFJc7y9dmuwX0Rs739kCUpHarDjvYbDC14dNzLm4M1HLnwr16mdPhXfmDWZAsV2N81U0k5/Bv9RnpQysT+5k6/nnTvdVKEaxd6RqNxLp5UN+9sJOOzUk9Wx7ATQ0pEc8wD1AqAfEsCMzn++tEXlYnsvpXgCJSwFUDjLK4q4Ut8cwZd/svhBTvER0TIjDE59oe4y/UfJA+uhASxWWmpRUO6MiQaORDunL8mi3+VZybYPSnKFAEEPcc/k19/qu62v04kgu4OV/72yr+e5LF79n4luRFIuvyohRWNNXOs56+egcTWfsgnHxfT6glu9Xe34beZq64PODhRZuPLIdG2knoISNlvWgh8tkiyRqvHzpThOMa5E1T8l9OgJMXwGFCXJ4JoBq6uj9jVrjWIvfZtkMzf7zEBgl194x3AQ/J/+rmCGoCbBZrf9BiHJ1cs68dfXfj/nGfUsUBzAgoDKRQfUQb4hZ0NnJlWY7rJNyGwbRVfKcByi+ONNzItiuBWulftVASauO/ms/rAYEHf4m7/sxufVkbU462PKH6pZ6hxAbuc6p4ZgSiAT2qe0Yfaeukyd3jCre9ObS5GVkbRrvEBXxd/wYDotuuzisa4lUzlAVlUYRVNUVIdoQHydxgiOLBJjmCcrYJTdeOsbnxm0JVMZKzs9fT6WmNDg1ZDMd2KNGQU+1qqvf7EGwiB8JC5+lXRHcehZJjY9tZBGXL/yd+3yy4P8z5xtVdN3LPM30TgxhQsgEp0IQ5Q4YQZ/va8zgFjjyOlLKAdMPs6VpYIBkbn0KMoZx6ElTwm8E3fomEBMW3Gd3hk9YoGDRcifnJsTWfr/jXPUpxfEoele1ZF6UiS5YEPn9bgVZA54eKwaGHBzFEUtBjubdyFPo4w48xMHWfzwOAo9TQRRL+0SU8X7gnKtgSoaHDJSZ+5oVHSWWD357R8mpt46FBa/dzT/uENvjEhcBQceIoNk/5eO0waEDCTPipzrIdvMIy0uX9woAKAJ5UPzG30sZ8ANlkdEeefd3N7O9+3LtK40kITv3OtIVSad+jmNVGVOcDhvgyHh5tPbCkkzE3XHpaBTjbkszrr2ZSj9yd0evVku7zqiKun48HUMyxyPeNJmPjtIZwHxU036CEP/fONFmKwn6Lw+1387OpovYlXzkxKg+dgwA749ksKwCucJ6WY8O06KPBW6uELMWsMz2Lqg+nZsiEQIYFr9KyGNu94mPwk2ae3nMf3xbsX3iXZ2uYJdyXK5ztrkz9VhcCeg5QY63CAzyTxBtJE9P4HCDeERTJJvdxB0nEwuYYQL6UhC0/l5fznc6L1CY7ISWY6UPqkIMUWOouoRbg+UpHHfRLHXEhHlT4ZG9LpHJqLj3wjtZLNIsa4y939iZnGsQAXt7WUgu4Oyeh+ZD6SoVh/sytrT05STW5ztMcS6rkObsMWJgVWRgxVLKoEVknsomt/W2FSYRJ//63YyoBS4Nh0bHUuciYyJs32mHH/Yx1gy6kYkZgUi43psEwsNcOwWnU1RvuL8O9n9aMMbojrGsfMqjo8ijP1IydSQxF7oCdCUybpnSr01WcD1xShPylcAk5a4dUOnCtb7DzP6EvJQ39h8QtTXEjbLNsHQYjYwwwPuknd9C5G4yXwHppkDmiUED0IL2EqyuyaInl8kKm/Emh005C5THAbVg0nfZ7oeMdijoNyI2590NlQifBzUAwrChNDfRFS0tTdHKckMgJ4joYfBDhlZOctxAymwS7V5PZw1Baq1IMSTjTfzHeqoBS5uSJ2URSpensvh9pxHyuKJNFK62qkao3UO8lc0jjSWwsxA+PFBfM5aD2JzRvnGLeVK8xvVMBGJSFLztfy0abqKngzfgU+iVSCGEOe3VvoAkLegmDm+S5NaJl/BStTHMD97nZTlCfMGySLSHEKSGS4/wz38u5CeoTjzmzmkTMxzqIwOq8QE+xMgaSmHPIZWeetRuqV3do+9YGBjCH+gddr4XSw4acUfOo1DJ21hfY9TJXo4370OwrzWslJBCQQe5WUwPEhuMyHkyX/JLEVWUUeJ0XkN9cTKhb4mKkuFNBzQ9K48q8PCNfAj6ZwamKbtwAiAODEHtv60y9dANGnGFB6ULOQpVOzsUTTg6q7Zl2NfCdQ0QBgzRhW4DpjY237kSoyaCByA2317LG51kKkc7aUl+Qa/WKGiW242TpMkj+hvv+aUnG6wFm0jR4Drqc2y/nmbp+ZihnhpYsV/xx652Tl3STPB5ys1plKoNib+dQihmyredrkNNBcjVCLqRHQo+eN44hEbE4WzJglkZQ/WyhJVUNzAJM9PqaKQURwLNHjA79XCZuGPjKqo9BDcatuAqoe1HT/G2zsI/9x7zE2dGlabCsX8eMVlmkYseqxkJ8G7i7yPWgzNzxgMYoHQ7MLwDzSz8KQDvS4eubsgvyzQQhqK+9zGkrJvGT488GX5OYVr0+ORMkSWU89cYIaAsn5U7AtW+uZeMpPt3R+GL6twMYTi6sK3imuVO3DdkZ7+anJePKUuiVxox+olvWdkTo6RuRmeG/Q/bhZKEMm20zc0uY5LpZFbKhuMJFXgiDU//s/YBYzpJGSb8J3AhoW5L7ridT5RBh/G12W4CIQ87YSHoojb+J9+z4azjMxAEcidI+zlLDvc5yRku47VJHNI6+444nEVrJxHQ9RyZBb0+7OqYFG1qYR9U+Y+OOZdvAJXhQyaYO7BuUaUdL1/swrrjHmQH1n77VCEd1wIgNvSxJbgirmdtFziIIKwjaIvrciwTmJSPTsA4LYy/L/9/9rE15Lj3xD/kWSUMsHxVdDU8wRacki52w8Jfd+SCg7H4im9tuXCZuZsEyoNq43Bx8swvMPFQYVSFmRR489aopD2SHvisgcRqA8dp3bbTIPcGVr37kqGEYTmvUJJyYKL8uiKWckWGCRosgffjOflnqhWo+g1zWanUihiAnW/6DMTVUzXKqz2LHmoqa0iOMGtUCKzSuTpH56s9AWyfHaOt3EkPYVn/Owcj4aAb2UGt+HexHI7HFVYLqXvuAnrJg0aKNxnrEQU0fLe0h/Qb2l3nYjjR1qa8kH9Hlwg6g75suvGsrrlv6DRl12bHHC4ikEU+HericCc1b6dBCpHIGegPKvZh0lTZmNYIGXFJIxEDeIQJRpiuftUy7faNiuwgaGqVv4LCTv9blhrDlw/VUQAXP+4GjVbbllom8Q1x+qCfe622gNDlrsDsleGk7/hJ7LMo4spHFHJ41YfCSbcVNcYd0Pcx2sd4l+Ji8hvkaLQ7AIeSeL8WjpqZhgs0gnsXW26ozVcTky4+2qvi4mTZowOqjVSmwrxWhaMmRaJJeY3hpLdhS+jTwjU7he+ohlwsUg6UuLEE3HuJaKhfXZ/dwYN3IB4rDukGKaNkZZwOOSa2JOyvci7QSc3Pa1aZ6Fx8jCkxp3Ckbegqk/cSTB5aMZ5I1MzujJXAtkvbB0heceJ/6fnJ02rXHY2TgPbO7tgYqHeJSmSMBBf3XWl98iAvWvrvuo2aLxOHw2Nf6FKkvtw4ooh2p8eKwpzOm6ivgY4m5cg6+fIjUKWkkLJCXPdKbOyGgsFytkWRFOPWzca7wG0LdD7nusOglGDlx58U79FDbKuTBnhj0CqcvtmEFvdpDNXEvw/76k/105cgExFokMVWMOVf1nNZeI9g6SPBOELNrOkfbQm0+QRL0EQnBEf388LtDhABX5IllI+/mdthS9+CgGawP+1DixFHSYq1sMZAydGGu+WsWvPs5zXP5S5AYkXgRrOoKbwnidodTfaeYC49P9u3mpWoQJKlzlU/o5XGs340VC5E/Ypdl+cCKUz77ig4AfPqtR7xvSMCirbn5Gs1WkvovINyd4LAKlQU6NGBUb9/eKJm+RG15ASd7mYpZZuBt9OoExxdiMQYwd2/dxoWnID2z96VBC7yeBsi10L7Y0R3je/mDXCzVrpQK6jIwAomj9uAyNQaZhAf6ybXSr8g8AxwI4a/xnsI242O03JoGQGPb6+4Ev/qhIQGV8xOp2qUsw89NPjf581BILz7fh+h/QqZ2978B9a9/yT+/WBbTJb+JyMnrY+RzP1hn7Sa0WIb8mkoi4dK7n+OPgO/Vj32UjWSW26q8FnYdtnpMshnXORr3J+madNdYo5TPThzD+JFaE+FzgNMH8A2aZLKLlp4MvnfqRsfCN4oflRKCX+/x55S8bhBCzqL0JOLbTANMNPD1cTwY7b5uWjca9E99Y7H3QWFZhGtYcp7TNa/6Ttzz3cyv5M75fSLXG2/ATYNaGMAFNpxB9606wGbeu60YNR2xl+wra4pSVKJFHDn56MSJ6l9Xj9mBVeXqaT8snFn+WLuURFdUrMkjsyKqHrCyaG6K9s4HrZEhP2O0F4lcBUAQsmhK4JJgHLvqAhRA2ffNl0ONoHz6P12qosB42sxSMgRMq8Px4LEcgJr5ZI//zz7BdAd1Qm5ckhj11NuGqnUJ9/KXHPndczbatTJbnunBvwP5AtW+8YVvxKCoyozgLEMLwWzACEY5gSD2gNE1tJ9mSD/Jqg8QI30GOdx2MqdhBXq/2tDs0muExIpABxjAMQukO+YnTAZBAEE+ty24u1Gaj1x+Dj9xeI0Fe6OJHhtCvDcw9hItIMfzbXNsIDb8aoO8ee7r+3WOKE5XUx2FDZcA5eANo+klMkM2ILGsDNKFcVFWJ+7aOI7iWVHSzOV5kAw0FuzKpEFNkeVS7irIBtfPpTDwTL+POQyWd2DQz0TTSzUDzBI7svz60LiYtznOATLUyzyhdvkBkjmYUhxd8YN1t2oJhkP3wVLJPyR4oVd5IFu7BId4HuAJ2h7gBcKMaaUbAaQb+Hk8LMR2CA/lWTrxt0KiRQ999AHonqBRQCSN1wPXxcfuk8HWOZ/EaZi/5VgSuWYtY1VpiHYsFY7xqs+G/e+bmOscBygQRyIeZtfcLqanQOZm3xRiK9zvORYLFMUxWd0+y+oseEBTVNC0HEpOiSF7gdhmjq5qkRO7Y+tFNk2g8r/ksIU9i2NMV8XOcAabRfq7izw5vnYotMPwgkpidYBjccTSKYTR4nKcb3l8r/4QGj0QCJs0hclCG278ZPU1wqyYCFboyZaiy7DGn1w4ihEKC3ZhfvwFXi/dt+/9x9h3LrSPbll/z5vBmSBCOJBxB+Bm89x5f35lQ1X23I3rUiqgKiUcigcQ2a217yE3hQNdAcOGaVokvUiibIRWMPChyh+675Acs/IWE+s78lO1zZnVZVgZVclDVgV8xjq/aDCdCceBJGjT+ZSiju8NfikU1EvRjKS2WV+aFqZxoLgkQX5AUsLYG/4a7KpWucVpWeDJnAdiXzWJsK9u4tEl9mKWT+MgypK7SD7pLbzItQ5NhUYQM1NNzdtrSJ3Lq7ibxc3qiys0fUQNPU2g4RN2jV1h0L2pXonlY6dJcm80pQVKQu7/n0lo62yf5pDscbT02w8LjQ1SxwYS1mh/XndmVs9nfvFdNSFdTK40O878Szc5ILkt2SIjT4wtggmV+zRx/6fxEk2l7vGEshHH3+HlXeaLRcESXsKZl0Xz9vZogFoYBpNw3L/YeiGhCQ0t6ct64GWSFZyT72woJnFFi9/jC7PMq5H65mH2zcEWzDnrWrgu3WaUUS4yu4hTed8pTFRFApuBtsmAaWS9bLM4SfyKFPlGK5CPvs7Jv4kSr9HVWqLvQNw66VPLZSyOs5XAnOxws2PHGuTASzNac+y6RAJctC0kCtw3ZOTJDNtgyyx2TBlwHzj/gXXm4oWm2/tm86iBmXh0QcX4e8cq/yErmsOdowXx3Ps7Ym2ktYCa3rTcvJgx1z3FgJlODs5loGA2mi0OTWsG0dJ0ahiGIAMGjV9RMoC3DnGgR65V8uhfcx/Mi6HC+7Kk/qwZGZGkIt55U3UZ/hpsbcWk+7ZgM5ueLva2OGuWYLss4+lzvKUNOyiDFPfPFZJcO8eg5gW8iHUiYJXRHz1Ces2RDRhnuYxQZKs/W3J7wnOYPG2Gtk6SU7zklH0NuD9TyuAhXLYbZfjLDhPCJyBxQ5TQr6A3oV2XHFRMkh8pALMUti/Bcy/fcWDQ5IZ3V2EOjJyMeculasvI8qbGBR/Lxo3b6PZx4j6erUqzmIAfOD9lqjtHx0yNSiulIvIvcX8ysP5aeDDoytlXqUXiknEDDwMjceQuD4fcyjgVfvwrhaHpVLcW0rglUeY4swK1ZHf6+CDx0oDwSKcsEwue7sTc6rRJYWFZDsYLv+9vlqT/QbazPq1ZZgLQot3lE5iQhmUn0Ner3+b/+oO+nHOhAEeLRVKwjN9+vf+VZmzSKGRfeyOcm1nKzTprhwk8xkYjuE6p5cQGmQ4pPGPRL0gHY/fGzRwAYN5qPWqf7QRbZ2bEx3TBWI6Lsju2R4DRPYg6vdsFoQ3yjRATn+HHMtydZaPUTGKzRmO91D63a8JD8Exg21uCd4TsDbWBzFQtqAf465GeLeGXVHV52BWmGBpxet9s5xHG7Gjh0ztkPZrrlGFbV5DWexYqDb0J72oa5t7Ajl9VOtOdh7gEfSyuDPZhwud1T2HPYszrZDbAtCI+6m4LryyWS7CnQ7S+mQu23EfPSqR+pfOO4ZVm25PcE9NlYhSOIYHDKmy81+SLiTwbhtF999HD0rCzLYm1vlyx7RwJ5D/PDO5ylUMa4PiVNKx8ForGurlm8vYJ5+XgZTUexDNdBGywLjSa7ZUERWfW9Slhm+8Akbf6yw2mF3qWq4Ng+vY3L88+DYTp2HAfFAbfxeOwscr+JmOEFs/sv8RvgesvLfAZPl8Z3tl1YW12YGHisg2ZZFM/xpyzL71cojE40kCy7NDnarMEvYQmfXVRrmLHIe3ueLIkYtm6b/2I+CvIWTl8gURb4VbfYnXs9O45o55GPViezBcoTtlw/fwA9wBH+eMnWjw3pLxe2m1OpZI5mXQxyhlew75cwjyubEqx8pqqTqnAYQRJIi7zkfsaYZJHiym5pfvYJuJ7F19sW6c5WuI1YhXp360pDeFRgtpnqsfbXuay072hJF4ctRgszsWFwKl20Rrl02QOKb6XUZkziRSX9qWl6FKwKo74IXbYv56oKeFXQAG7Zuj18IzLeL8PUXnprvRnXpI1FTnRMOrMq9tjxQhgRynPvmye21AzU149ltsdqbPB1NjOMp/JNYAzfwJRNhVWeRDLOJIua2mSbZ6yNb5zTX4Our61AYLnwL48cxwcHofz66Kkhv3rpgdv2DjjUefuhiUUfDps5wlpZS9gK6WfrCqlnO48ih8mlv4XcaSthiGIkVa6Mb57yPDY98qJ5jiCtQjI/6Qea6/BLTVa9vz4VtOXw/gN2XTJ8ibWtOqeXg1spv1Wj6qGde/vdi4vGAbcuL5pKVk/4zwucjL4EdbS8R2IsYgF4OJzm4sCM8r+cM7gTdGzZz9+ECI4zHro+Gde6qG2MI4O9thPdzxDbdG+LeJ04DoTzB45M8gA+fKq83IZJ9tn3nbhRP0wYNDRdTxMOhPPBtBk+P6kNk3Hr+02h4VG3Y0izaRgYDQi/h7aSrj8zysszS9NYRt2bg6JCjzOGLbDXwGy2/QVuRd+2KFyWWg7SEjHDwEAzbIVWnR7NnklpLwJfGayCPJZt44WX2gGUr0PvXwRy4EqiqmBpmrr+l+xfipKXWkelSTIM0dYdpCqu+zXJgWWnmlR4wr0E8/3eg+OUz5G43t1vYtxhv3SECjQYv7zQwNnkZfxu/WONQ6giEuLAKNgaLXQ2o7kvBJ/9KqE5ln9Lpl+VTpfDTMYiEw8ws7Y1fzYX2oJniBvtjK3hveAkbG2AJzRjdnmjAuaP+CxGz0qJq9W4QpT4KK5av7bh1C/+vYJr8IwsPmOq0p1kHh9nS41Bsvabq8WeBe5X6uPMimD0AqOR8/SXCyWgSG3DYrHHPWMRz44NM7J7TvybT2pmTWMAX6HbKZEKvmxVMFlHkdNPN2SsrkkZaR3vwnWvghX9Ig6fb8juRHg7ARHxjzNZvLIFKBTK8PAgFA1tj0XsPGDohhInJm+B55PuBYl2IdaPClNcCQRE48VU/FQEteZ73tozDItMvHG1ACu/obthRAFnEoHOWe8JUNHxT+oJdjHHcjgpu3tN4eXh0w/8r6HCYHCPEvtSjNiqj7r8RvbVwvnc2lsTNJ99vaQ/9qU1kH3ZkRk5K/aL6J/CLY/HAXDKRlaG3s6+iQEMPY4/wh9PUiqJvarCrfJ0xCooBOYFxRgWLMZZgn2T2LYEwsBoFRcR8mjFycMzNkmy92TNmeh1+u0774JmKEJZBqsM9MZKYNp2lGWkeEIPRZk+kyh6BOAIkTCZDyjBCp8agxMZ4B44oW1TUjbNfBJstrI7wD74G4gT6hVrCnFeIh1WHf1YlCUv7SZV8V0kD0w+/D78Qm/6Hhz9Tux/VHJptSmuB4X+Cgr35L7S/oJ3bqHm14b9mDXkqCbBvSs0CepwSZxRN7LIJ9St6fjmwooAIFkUe0qh4DyfsGGyczGKfFuXbMkbMCSaARGoeJ50rBEdzjBxymfetq5usuPzvG3HKam+kayyXOUd2sqarY6S7/MP43p56rMDvkzGacJ3nrLac2NrDWi4wVMMCsVRf8gpzbOqqEhvVLp+duXsoz2CUJW6J1IQtY6EWi02qmrf2bXs0snKW6QS0L7KP3aBckvMPjHgZ99vQYrEsrDdQWIjBPcyteaf2S/OiVs30mSBPeWvPbTeA1vNDNBWLBJ+9T3Lq4TnmzVyji28ucBaDK7LWLbo8GiPE2k84cZ0EU32S1BQZo+xlfRxvqDIL4pzbTvdC9UPAqFXxGs3TcBlorxOqP+ZDx2p36TpkjQ0rsqfAcudRr6njvHzOhGjGcu4DLiJzv4TPZ3bTkmrbDq+yaVlB/DW2tFTnlrrvRUaeC0PjMqcLu+zpj2uBuXdOQxbI8iG3K1uW6dPakC2YEEzhT4aef0xGSs5OK12sraeeEy7moQkTbBhl/Fn3y7fwIHRT4X77WbiYC8Th7QifqYX+dAuuLyX80nHKqCPF8saaxyu8aeKDv60moGQAt7W5+o2mmkGhj7crrAqfEOfD/3wPKYiCIWohoKFAWU+k6+WIdkD/j1eX0+eKQ1U5lRIgeJs8RfW0DGc2E1cZg4fYgLCMquROUfEe8M/eg8mG0udjD8r5diTBj5h8DjQFjPaqdtr33WqqBxW/WMRwS9wMhKqsDAjJIxpirjitXIKD+pqHfaqYXkr8d7LegYuAaD+PkQM9xVbX7Zrc6v5rzh1Oc7QK4uTua73vibp/heNSx8XpMxx1NBphv36ivi8GzYQZsBbq9WyF0d1yG/3PlX7WGRM8SQdWK1BQ/HfsmB3HB78jGb9mh3N37ly+ys9qYAKBZqxdcODulO0GqbYrtaTaaxHr+LiZkXeJ4DxVyPbSyPMmOLvoYQi865+TdegROdUe8V7f69bCGNU8xkxKntuiYrA3Ptx4Bia4XGFMNTYwtQKGXYflzgrPXKKNGik5ksD1gSQih0XNi88JSGenFGwwTV+z9cOY/cqwoeGCz/iExjliJyfalJHNtbl5zK77hmzapZias+KL7kDvODHrZK945GWf7N3QCZ69xuAEEYsxfgGY5rHbnYS4Gretm1WHAJ8db0W/q1/hvn3ZFtCMYOv9J+wYxbuVgOB1jAuzUmGcP7NNAHu2JsWTU/LNj7rJJTiuPk198BcI3yTx54P8+EjQf7uUcl9WgCv9ZkWOxLDdNC1Fry/GR4/1efPTg3IV1Hzw13Xl6BoxtOmQTvY1zfr0kSXKqS9lySzq8fo15KifdOIQRyRtJ7iBn7FCX0b9uvv4Z5O3vVUpYXLpM+arsRwHa5IK++pfaeAzkXiUwdWBMBxOiTManAyEVoRY5x+wKI5SewqgZk9kW5ilOzS6LI66n1ZZNHeQkpzr6wNN24KjssBbh3wlLmjObZfZpoOWngdj3tF3Q+/nDhAcYIyD8ISAglGfK5/RqMSI3bfT6LOfYKoYT9HJpNlKa763nXAllooU0gG877LUB0kTT52tRNGIwREjb8WlTGmCbA8BLisvcDR/dT59UWwcKwUJxVv7XD06Ou03cxEXZLjWvN+YbIQ6dZ11SQu3ajNZVkS3Ypty0l9DM7TykPjiOAtHNArsl8WCBLlQ8nxBYPYn+n4cNAOdsyb4tfKBN+44/wPAbFFW/O5Q4RlWNrqPDGszLFiIJjBCaLNEvZL0tE1RjO0+u1R4q69JBLY0CFjez789nSS56MBHHa00fDBbCuvzbqR742q8ARamwsF/A900Cbj4Z6nnOysK1kwxxmEwrXte5N24t1Furbh7kHScZkQJ4zF+U4e6Kux5P1ZtIWIwD5czSqOw8LkSLJNiFsJKDTw3c9qKqg14yVfw12PeDMvpP9Tg4TVmvY9IGjs7r45Rj+b/lMO6KQoDqA6V4XW4cjEDM2AdovuCZ4loUc/K3gQwueeV7ECT+Xmf7vl+Kc+9OOVud8rwO5hYK7VnIO3PNBopgTb80yToYL9DOxJqUc02y7iNbP/VCLtTuEWjazFS53IXVn+0ieDWZLmwxITUb1DolCCHo9vLAzkcCwSjQq6517wwSbVR5vQcX9T28+2EeI9d12ef2MoFejMHlP2K2eEAO41SS7bVlzCrjIm6zzRfp+kP6t8MCmue42SLK+DOzm9c4jgD7AqCUp2HUUIzZ/figC2LEv1XXyzeq/JdPxzE1x70QA0cXPIBKZ6ReKO5S0Cr5fAN3ldYJCKxTRlR1/Lob3HFFyroeBYFVjD+7Im9vligQGi2lmm+4q36J5W70gD9WSZs9CbeEqSNFvXuUvAc7i+KZ9YH13e9gyDEE86t9+AWfpy3QWmAESQb9JnaaC9mNcVxoyeoy/ibSFcYTWiMW051hvzKnpmOt69MPvJJkBZ4R1S4Mlxjy2jdW/3r+g36Crmj0q9KUHzhA53vuxYY7jiuHIZINSThR0WhIQXW8ukjI/kF7wiAoGZGDhM5KTurQtvnIQNDTIBAZK0EhPZHI4a2QgDha6OtqEbtrg1U6CAIYTNRlG/KX1BJoNpnzJS7vDD1aSCH2eKdEFF+gqsl0p4YRoMogPhSWZ4+s9mmwUefQDwkHpSGnFRGNUtku0vHgfoQ01ZnziAcV4f3a4ofwwJJi08CoiiXUWW9VSzL3fAjA/w444pPo5CBjjCWYSdRPhXOL5jSX3RsM6Eg+I4zF5192gviq2LppPvdDOOHDcQeFBngz7QhCz2u4blY3A5rSQUha/jatRhrrW2pNGSDW/By9Gq2r1FHYZGPjSBQhJ/L5qBTK8j3vg99vV/7torqN41ZZAhtgzASzIJ1AGK37tg+m16ewJbCn7pXt3qbfSGbvj2NbJPNBKvwCQdubWh/2DUqdrNHu3s6IKxEyMBfA1l4uLVtcOXylSfahGEcIDq+CYRY41+ct+X/fa5c4l156fBpRCiDWuQreOOwKhDDQyrO0XzwrIs4jXiLG1ipFw4jc3nU41WLoRanrAoihJ+38+BeVn7sOVxkrw5wN8vspa9ZO08bnX9WLOAwycspr5azgK/O5IM8M95db6WtmxcNuyiINg8ip62rQAynN6kdrgXgsXhfZwwuUPEiak3VbBumed+heWDYRGmvN9HopW7H1OryMyQvIm9yfCSfq4HiQQ2ziWpw/LoQU1B0GYeucO61UTiSouH8UKqMHiifLIsmmL36unsmYRpUhAAsfL5aDMIW11w/6i46b1MhC1Add24n7WNxVuIDdRvnolxgs3SdMjxLRWa9zQcmVue//NvtVPppM43jdsLyojOII1BIrnyS9QqM3A1ZE2Sxp2V69astTW0SweKtVpvXVeW3qa3AFABw4gF5Fdt3cGPKrnLueglEGj7T5ykQPkH3Nc3C4Xd8GKlFfxGAZ63yuqqhxlHS8URZtsCj5RgeWnrMCVM4uw3Mk4q3XFm+vAwPnllm1pdcYC1fux8ry6B/mbwOuztxjIAehm2sR0kUmS3R11IbcwGn5bVqB+65z+bBty/NSCMujNkwDhmHio4mjv3Em/0urdYUC/6YFKpmR360icyd4oV+GJzxu6ieboVMSFEoldfhcXB8ltmnK+bLQCvQQRNt8kMK8MTZlwPeil5fDzRk0L95xqCc6I+TrSZD336t05HeAY3EDAw171HXMoZgALZMD70W8IS+nKJ+qia6Du9Pm9knnvnrHj7K0+CrvdLMGkLXkcmnoXxN8Wxv1IH8Qp4dPrZBnTGUxYS7GKQ/rhTobFUQNT/vDFHuvgmbR6kCzTiAE+Gb5u4EdaKaUdiPNl7eHVW8fvNVIKFMGl4nN1gdhBHQGqX0lsUkaxRqLFTUXsjZ5XK4Ge2BdYuw854enaU6KBbVE3uapaxZh8CUQWGvaey+depJEiFBoR4PRbgLCu2fE+eKpyD/6hen3AMvniwdxZJMG85VcQSvTUQ703CqCL6OsyOj4+rovBu2zhPBlxe1eleNYy8stxoQMZkGQYSj8kgyeR1v/xTkRCTMyeIQELJTjtY7hUK4p1fG6Zp98h1q8qS0KgtkyXp2E7dU+off1tC+YkH8eo6r7NSC1J5H2pntcfLZEfHmGz7tMqRIlo9qjUHzT7ZivnzvQqY1TUeprLkLFAwwphKYzDbyCDZWF2bOx7orTtN61B8kTcxvZuDVRoghRaMEjEaNKpnrMqXq5s9ctf4gXc+gJr0Bj1c8GeR8FmyTzuNMSsijWfMfEp6Vu5AhoQUuITzTtjR+J1M1fl/o/HZUv8MHKLoSE+CYkUVgu88PGC/x+laAx3VYQrBDmfK74ZjrUmhSMMoBjSufzUwGekp+r1vTsnZQlTHsbEAP4FaDpxsuISghzTzrwa9Q0hUNWvsNMl/cjoVDXtKV8rYK7R/FwHcX/fwMdJy9ykAnObJQtP52vJ2kAa7Dr7UabxErtMITM70c2fevTawb8gTTtQkERbBuTb4lIgRHXeVo1b+5n2Kr2ohDtrKCt16k5+BxADEuzJ8cFsl6bFFNs7htrLM+Qb04/jAo8I2iCR4fXKbGqb9EpyHEYh/Cl3gdzwjh2kUqL6Js/sJY5vP9KNIF5FmnRnBbb0iKR+X7ZtIaugWrpbMeTQtjWTqPN0+Qe8+1opQDe05jG+yWlH+oqL8T2UIYAXc9NDFCYaSV5nqPfrvXxgE7fV0qqFxZA8XD+eJ/l6E8vaMXlBfpHtRx89cVDv93WGeStKXm21lcD7HP/luTd43LaM+87Gp9XQHoIyVGOVlXfmcEAf0SNRBa66fUKEsb/+MDi3SDiABeh91fN1tA08v9bpt+0YCDqUGjpHpuGTjEEvFx7rDc4PCsKKZRn1RJuoP32w6SylbIqh+jkbayoz+QhjG7BcNZ/YuukLr2Jxsz4D9ODDmWa+LNtNFIWN0Gskv5+F8i94uCw1Yhbq2Y8k9mrf7T19pz9cwLqKt7u90rrht6Xqv9+9H1juESgxeIavyxek5L09dJ2t6yI2JKolP9/EJjMwRHUWXz/o3BgEAGZ28BMXhtAb4uhjCujQF7/4jASk1AlX/XK/n54eL4w74oK9XkXzp+lkcFNo0HZduw927tfJVV9fio7Ig62aTN6w55oFb4bjRTB0D/eDGXZBId9keFNVFU9TzGVPhZgFEj+EthsPF3tECoLOWD1F0UdS4cIayLUuz5PFAoCHq79AsN5oGIEjjCBTKv1dcyXPcAtfPq4S8XvNO3lC8oWOO/QHP+Grr0ZAs2Q2r8LT0nwOgr0fAYDPLGsrVJhzas5akXx1dJHfUOVlq1IXtkCSlj9LGXxcyLIU0jOgiQpwGK2WM/boqeN+UJei+dCazdoyxboxPQqxsXRpaVNy21vSk3nurbSYy3+vSEMAmOuRI1b4Yippjr+q1gI9dyFpTQ93GGqOzjTRDFoakW6uxCxgPRD+JWUAxW3doVC6ASXGWw6UPxsvm969zA4d1Mc8v9nC/f54vbOO4ikl4XImzyWZVn27U4Lp2rVdWM4FGZ7zSG2w1d78UAj/o4jkAsq7aotBMDnjsLXXynSjRfhBVp23Gq79lg0F0o+98Vn3CNszhqArq8FEPUK3hPdMHy9LxbJDjueW5UW5RQ9PUYEFudUJ7nAgkEWkEGbo/3LhZNk0B2C5ZMepSndolPm5JsGFHzKSGJKwO93iPde85jcbaAxgVeB/1qgbyQMym0F+Q08Wa22BHceeSpIVDS1mw5cwdXwwpw/KcO3IoPqS79jNMGuTZkOYngXWfCMLw4xWusL/gUfqG5KhfKewGK5t11VJr9pt/s2psHaq/5z8hYy4A2O3y+MpF7VMKtXhzm5xYW9ZE02ugmVvwWTTpKuPQd7wDX4ed6tfxS2LpIF++eTJUlq3GlNA7wgpaHLJM2Lx8aL3uR2HzvBpuF+SVHE6irgeZDP/pKfJiDHwLq8XrjVnygh6TYpI8iIArMCYzsxXKfhwHp7V6P+MeZkQX3fNDEPeym3NWV5wPFVeImzEeI4D77lEB7GFV4fRxann/jSbzaGNyJoFqWr7zlewRmf/dWdI9YBVuEKtLPe2U5a4lcUYrRZIkXTTIHhmfPpDTTO+S9UHGAKHsp9rn40WDL25Okcpc5A6/zvMHg9Mt3pgzQaWZobx5t3v1crVtWSoxDgv029+Qz+JNO40zfyvkACReshGfvO8eLt9ewzeMfkpB7TX+TIihK6GGrgsCPMUoivakCJjTF8xTubCC3h+CrGswaql8GZYVud4Rgw7xL4liw+VJvBhmzgGmOIlm8W+F2M3k2TtfxbA/FZEgdMe665Q2209JWflHJzbx2QzFoWn4y5/lMADsPEI9MHAn+ctxFu8bw8jHGHUO9BFd1kVsYTwpI6XrmFDYHuItei9wMkeN2HX19ZmjX7W/Qi1LrY5gcXW4PrHRDdcN4G+HG7qULxAB+DjSlQmEDo16darPrjjTnhwLY/QwqHUqpO7xH4v9KazTzqPncvHiUfh53irXpr/R0xPoorPWX1Y0DvJ2xS/wW3ee9NPHmRlB9Wzd9S94xLnEJ/jeJrzi9Fj6NehELyQDTPzHg6EiWMlLoPSN5ogtPKL57t78kaPK3Z0vRJ57mkWkuvWid/uq2v7RYBG0IurVwT2TiaNpWgeeZhKahVxdG8ohcgxRqQgtwqIQuaDqT8Mf8JBJ2MrFUXiusxifz7E6mGy2Uj+p100eVWj6cM4l8Q80O9gIGXDrfh1ndaxMelcqHcDEL304MWGw3nXtJSIQTSJ/su1FYjSLU8Bij+00nHNFUbJurMK99ZAoFaaJfeC1eM1FVSx+tb8dh0Rt/hLlHJ4oVxz9yfyn3qmE9U6jIvZtaWYfToS8qAb3Dqvk8TnqWjjZwBNZ79OLWsMcYjOSNDmKcNs1t4ahzK2tUIZo7LXl0lnWPfZVomkbialUvTdYMJqqsobrxtCvydWt68y7AIrIPdBqHmFFUvJYnP0Abp8gtbyCdTG7hxdpNmHZnvgMi/9q5qpSGPdiPtXZ6kExKlINcB3MiOawgg0oJ6EgykGyHs8ro4IZUPiVlSKUa6K7vOE5vXUChx8gYvonPbJt+4WN+LVNQdUE0geNkX/64DYuPxqpgdFxKvi4tIB3q3CQk1Yv/dyhXlDgwNy0CH4swm9ZxVr6vt5Pt2LgX5CDCvlj+Nr0nl28YMDUGYOGquB68+79UEcpxS4qelzlRCCIS4eL2r2ZTxMA30CiP+UapfApc7kfcm5NZjRKsSl9HCR9qbDqCVrG//mn0lqJtaPndyHgi2SRCrPn5ivdptrrpvts3x4EsC7PX8QrmDrn+vjbZjyff+FZFWoNm38jmduRD5qsLLhcihU7Z75g63c88ZQVZft7gBHSNlsgOtc5Y91eaHXDKyoLMqJRGcbxJHto0+BRRWyg0xyTVjMBz4NoMpyv6k6ab7qbCSRLr47LzqFJshFrE/7C0ADxpQZFLwVCHKlRhscEexkKbXQ2wYL0HFWjxCTnavDgk33QMVWuHnJlp1d43wBWB8W6+gLvSRlskVYdYqY6n2N6WwpIWsIoKOb638xVAgKjO8C0cd7b1DJOBSb25WxzSvmFhZprS06yg5/VyXcgWGc3yShgggxOjGQ22TMpAYnPisnewh+iYWv57ZhorCi00lkXXD0mohVghr+UTxTyuLOdz1jVcCmlWqMynMPHqnMyytmoSpecHTE1elvA5SWGzlBVh+eD7lvVp8rpdyJmcPXPHNby/oDSju3DP/sf5qUAdecPoRG+zq8KlTdQ4nudA/eCXUYrjG3cs+k2aCtX90GcleWjlmMJte3ToxQolDPGgPnMOLK3Wk9NMhLHGZJ92Gaghg5XZmCysY/bx1XE8LDil8WxTpIBEbYvAMF6dI2U6Bd0QBmRNCeRuyQHZygHsbQRcDvprxvOk4tRMCIY2IiVGRpVJIrUgMR33OLSEtrJEeYZUXftuCPKzvzSaGdRq/qTow9LffFDebzygkyeRLrgtHSHSrkHQv85/xK4pfaTvxezmrj3M385b/eRuDhM8bnpoH3h5hColyRe4ShRczLkrNZdjktQmcHjmM6m0Rdahf0ezfwk2CTRHW2PhKraui1+M2rryJVlmYz2pCAiPWJaG3oNji4RacBOoSaU/p9ATPPOD0viSNE3VK4qq3SUyjrYays6kTXpqttU2vszEEtGe8/wL1sPQ+u7ris7plJKrY9dArQ4ynvl1Aw9yrY0kAtLbopjVigJKhI1wED1Tw57VCUt2nvFVIEtxEfTRwq7wk1xRXrjZmxSUtyiu3DL6g+thVI50gB1O1FUBAp20aTNJTwrOjOMskGTED62FVm3DTNf3hxo6yyLV5DdhQFrRVkEwjKCtFORONGFoW9zreG3eu74Xb+arUzULAZBFAzVYSaCJUENO63FDbr07Z7FGh31Tmf7pVR9pby2c8iQcy5obIuNuzjE9y7/oujPrOydtq36QrGwtBNSjT7er8Yw9MuN08y7F18T1FPO5jMGGBa19R+qRZINPf4ax91+mbXP3+kFJoUGE2UJ1nfgfRoyjheXNHqHmnXnCXGCVx1FI4sJa51YSo9qZzPdtWfHHsui7jsA9cYnQb6RwyNXrje2Tn0IM4Ios5TwANrrWPJCR+Fl/pNnCJ8c0c7Qmx4UUU0lD/Qavm5Z3q7qyvua2QhzhxfslaTt6+N4SSF4pFq9C988lowwdvVFfkYMerhEunjlTVLNSAR6yRBsxITYGMlvFIm2OT1ZKM5HlHyJcYKddZzmNNYXS0gCod5D7iwAEjBz9CzIsR28PQGeCYA4azoqqwawY86k8YdhqxGljHJvJAyhd/Vt6Gzk8c0YOwTkUUz7NHFMAd4k4l5u609FUsqgrzPlSszJADOGalSFQrCgA5SHY9z+7VacH2YjaYEYjcjdGUpA9nZEggs7DmAFkwMrmHB9Tj08IJpYjXNTt4tn/3qIaLjAMdXcg+MDmAiK+C2Yf72fZiSaMuTlvimIMCwZf31lkq7kDl09OIkAZodyVjf+hLv115joN8ljI0Pt0bGohaxHizBw/BSVI0Td2aEBhh/A0HhER6jpDBF/Fn24SvynqIEzkmvZpCnOXCnRM4GaZONRPl+bRCer8vLeFhGJ3N9JYVSsqfQNzSw294F4/p7oX2yMbC2seTbWe27J6TqT1Gwz1/IMI7tm7ZlUEW9m57wuPHuN7xkjTgaNtCgrMBG33vQUydPl4Fn34pQkv7Irb/XPLtLIWdtRFW4euYdxFhuZ1i0vbDIKDWpv2cO6lx9pMh/EnT2cVF4driMGXkS1FbBR4UJjxiDSM36JilgM3xVOvJbQpv/hpSlCVvrXUaI90FwWXMtM7kG1tmTB7HryUWFoy+Iz6tXKK5l5ytmdPcafTS5xdy59lpyKtfRBVFHkkBsqVMkRYkwJZkzl5qLTR7jYslvyRkGgwAU2MD+ibvOmtfJTq2nhDCdrZrPB9aSts7oQsE7xgjJ62YxL5hTgOcvWboa3/sNZAQZkz9LEyAvQeeD/Psv1Y9V4Xzvc/Nhp0qk6e9JA3zFem6ec+mQVwHZ5C3DIIo+aTtnc43U81jRZMOzXvesrYEI9AsC8aADfZtfgR6cyWaj3lKf/gfUijywwLS/ZbExHAQFqmsK9N3b/BJTrnDUwxbx8fTWg9UAPcO7bGFiE1V1jwwCXoTcM+nNfZnBjgbsFpCnUEiu59QxZXjzDI6iWnyMqXEL+Npbar+rTK289Z3pkp8bAVGD6VjSyeA0yFpPR8S9EDSHQnbc9Arhihdu+9i9O4mhPpVkg3J6lyG5su0V4wA5T+EQNbmPcV0QZdRNkOrbn7NGxG4kQm/4bao2YGvXfzqN+yKUb9josS7MD45U48xkNVAtnG/9eQtZO/bt7IWjlRyeiKlweLZx9trSqvuEEqNXHjHlukYByHd1gN9WH/U37FMeEPv2K4MEIn5qDZr2YWZZ0yEXTyg7AcKyLy8O7d3TosFaBbKoIsJKDwEp9F+rPo7Ar/40GvJ47I5unZa8dmoxZVhNJbwDygyz6QM/0bJJbS5DnKbqBq6XqWkEAPY6S7DRB98la21jaIeQao5/NDgWUDaDOoxHQdv+6npUZkbHe3SeQwaiAo1PVvdnwNqSb/OblStJePcw89Y/I1i6++mQ/594MI1zpP2fev6Qx7B9AskTMiRxtTR6YNJYY7FtlN/4Jjr7zi3PpxVpEjkjavjvs+EIyVzfi4IctXFZ9YoB41qY7/B/bab14b4rjeyHSL6bkhGotKZgl5AfKJMhtwJaauxd1DDrMfVWFO0voA9/w39cngxfco/ThBI5iSvejLrXmqk8oUOGS2Hd25cWRoYiQl6BBsTJeR3n7jxr7+leyztSweFe5yhUQrkQfh4ZseNQPdi9MLB7xvBlgPweqMIpIOIRQAi0Z/pT29zwhotf0Bzn7mxZmu7tdDgw4VHR31Hm1TW8spQicRTrjr076LuOj4UOYHuorh2fN3yc3phq5EK/kAhh6edwSDuHh7Z2h9dAEEvx3v765y+NCky1b3+eCdfd6ECJFuzdGfYsczt92Q9tjDqXr+8SV4FtMJHkALVwSY8TWpjWY+/zlmW6nirHvLQEF8VB7Rpd4e4lfKkKMm1Yb0t/GUFuG/XjJ5by41SgKOd7//J70kNBXOr1mIPmc9C11d1KdZ2L9EkmDl2VuKyO+g0rQsIIkz6kNUHIBVmWaHFJvA5Jwk+hkq1YAvplAfCE4O3D2iY1eUZLIkNRsEJSO/FcGwBZ2ijDeA4/i1uZ69ZczDiWfu/Q9yhJwj+9wRws+nWY4gqfn7/+th9zvfouZqag3hFG9sGN57vmhq6CkE0oZF+hk9PjP2UX18S5GI63/zo7Qjqr6aIGHtP0oHVcRGhnwLxKeBd3LhlupXvfMGbzzjO7dMO8W2N3KW71yVv5m3t37usJ+V5ByB4wGVwWreX1HcGgthtneRQ32x8Nc7gctFGejfFwj41I/s+9R4Wb1E4Vg8J/7NCEhNbnZs4lWPSVr09WT0fwaDVo/cNZPjkYVa/obWvH3YGEtGfJBJPVFlY1Lod+qRPL83+yR5z3UwNTEA+Nhz6OovXaJuutGVXar9g2G19pNwd/mY1+zEUvy3qYGBP0ijB6G6pQgWJqwBK6ESOXE4UV70gxlqpaAvGLWjxi+z8f2qD4KVaG4whJRFTWeLFxhI6ZL/SX+ZhdAWaL2QLJTkkm/r+4DZO0pPC8+CPt4UMdf7pvMsogVjDj4Ca36RPP8qZcd+oMloD+PeLkYtdyOKUADNhkv6nqsG9XkckKi16gpM3X59zBJii/Mqn5yaLVH3++7D+GsyW5RALGVive/eleW592BPzMlRepMb9/RqDCE/dBCvyHx7PNxYF4i5W8J99DueREQeRbAE5ydZ1+eZ78fF2CcPfi1TFvZr7ZN7pkFG2fDLJTxEC7jMdAItpUIHg9P5F8p+UEp8Rlc0d6TxP8uWXmrNsSdj4fihom7DTQH95q/dH6AOUbxwtHXzI/mbMz3hHZBSivbbiXEIVsJu0SAEfpLsPxTmR3lBqVjwoTMGyEdCXvxz4cM8yJBQufyNI54NZAp48M1k9y3JfDI6bap6xDPuJlZvCKiIUvaV/4n//j/MyuDfqedfqFbENZ3H3r7xkIJ6Eww41Wnj0F3TPfGQwi8ZwG8Q7hQ9tSRUx0sBKltIkV+xCyONQyrhGM7RJpOKWjsx9+w8UGMs7uMrG4BxF98wMMGYDuyrMJuRsXgT1hqbQN0U//sZ4iMJ+HWGI7VwwXLw7l8tcxBQOPtr3P3tRFRDTyo+nxdORxS/QXmQNCBtXzTHEkRsV/Uwyy9leN3qdLQeqfFQ4eaFdv4g8cdBlU1JujGstX7GlU05tVt9EqI/O7ZnpxsOe246IZpmiQWFeiLiswkrXWhBddtXSbQ1Ze739M1bGzoSwOYgn+9g5nYDwAPENcxlxqmOZDyC+SanzXqlZFmzQA5gjNJ2nsmyUIB6sIdGnqeUFC+YerAv19kbLJ4CUngU+Su90E12AuYo6s+elJz9MFCe+pg0r6923eWL2UA182mTGM+379H/jiIEDpTq5OON1khABWoSE/6j7fTqfULoIReg2gDZleQN4xp+6y0pqq1tplZvFRqV/uV+sAZFlP1bAQkx1WJIKjoUYt5ZRff51UlYTNs+Kf0YLhGlPyivZwvN3Hp5bDBJfFhSBIwjifu4fClrsnWpgd+tPzkXFoC+FJgLkbmKgoTmNygoxJySWr247iFewg53gMqq4pCpzOWk7yFlp/13P/vWTk/cea3DvOnA8VqY1PxgdVRkf38s/5YuOf1/dmOsdyg7YARFuDimHfwwJZuHW38DTlEM2lBW3/G8FE2ng/obhdS/O66Fq59ooLuuIXMixHtIuUkxx/jDtY05tMLu3hpdi0aUeovQOCkPhvlaR0SWxwoe7CVX6FvtELc0WUvWJOHhb8NeEr+K5CKY6YnVXnJqFTy+nEpyGfBuSLfmUM+DevrFBauianCoCn+ZIdXdvXOQ9u/e9UF/Ce092TW3swRf+25DxG4Xzy3m15rJLjjr+fufLqjPobhqEX2leKi8ZysUVceX9HXrMxSBuGeuptIhvXRwywp5Y2i2UXyF7Zus5hWHF6FTWVcTtg+CYeHMQAT8aG0cDgHq6KeAJyZAJ3Xr38noZRwPtFH6ee+pN8CPQjQFtU78P1VQAsoZWTB7FZ4fE+M71/kIMzXzxCqiBgBduU4V+eXXKlCQ6Nz5CLCegbu3Q8/Hq7WcFyleBBn0OYYEkpukKaZH9kA767hEn4O6orZCUGScoE4IvUAoX70dufzVKSomYphpo5N78f+SgEsCC2KN8eXajNUrScx+8oBKUV4v38TzaPaf4zzBqjJflR2++JQpXpGKJoLsj/+hQIAzPNqv6p4OpA6FDpkgJw+q+7DJIVsARD9p1jiVDee/hmgII6DdqDIvKl9NK2qj90sk468iDmBccfY34n7mUrLXg9jwxbGBIs6XWp5VrO4dV9IZmPzBKcMAxP3HCjoIMge8jXifyeTTC8LWiWIG6oqJIvqe+MK+OpZbTSyB0YMZWr1WCMpFKrrLPjMnmHnzcjRiYzBu0IcJQzjH+dy4N438uYOmvFy77wAUdsBSxHaJSyrGjXaCvnInjQ46kpcWq52Dlbh4LRdjuH034HEp+rCZMDJ/PKkzRr/9X4KuVFT9VBISkb1qWlVcyMW7Qn7VTUS6fy1fRFERS7hNdulfu9uU59UUxHJkzjPx2CKw/NhPU25CroxeA5DcE2Fy9eqCQU5CcDzzdA1WSiV1XyF1p1kmF0iHot3Og6d4dU6rveLiYVL8KMULEsSasT/5sTZKpeehj9HyXjiPZamShkt7fdDNrLa/xOXe9xxuYLK8TqQHqjyrzX6PgwRzYHFdhWPZ2OD9dX79fAD6EmweBGkiPs5ZqGgPohyqd/5zoSU4PNw0hR+AC3pXokZMlVrmQ3yNkkHdmRWUHgmFMsUGOcVrakZyfYF0BY20Qn5zIUSuOv54sfO81PhxN/7M954nEpf5VhgcjeiSVzQv1pfxGxOR3KkK8h0z4usrM7Zh5Or2VN45kFu7fT+c/gvDh2geU+xt9Vv9XnA6rgl0M2fGjsenJjlBlY1vaujGMc4oKYvlbh9G8OrDfLLm2IlWyCtB6Z5ryY//al2rrLlXxZimjzoU+imLT5oWdyv/aE7HFroZoJ5n9WgegPivnWc791E5i6pRuX+qvRvJ/OzFacxAHcIzLp2jj/uttbq87wJc2DLgRkuw29aM4L9gcN5nZW7GjKzkdmwyI+n6OaSw1/BwOEpQLorlhNPILR5IewRgUtXmqKnmlZs014VDe3p/GxLG1k/mdFpjQYcSGHJm6ov/U/4xuerh7MO9H0KwT1dmOEhZt98M+y7w93CQd65D4qL3XXA6Y5fbwwV3/WZZoldjyu80pPTau3eMyf8hmGgeoea7jmq7MUdI+376gCOSB/GfZ2/xNk+SbgVhnPhW129vAOktN9RFK49zHe6m/cEsL+YWwRjbk8UhiqAZNo8e/8LMOhwcFVI09mW3WNEWdthNdqrozTPDftIQ5XjHzA0ulKuxjv+p9ZOqlMcvOrD+dOdxhVKj5e+facsMaQ01PWjskKOwJY1Zb4DEFNY35V7r696l7oErayml1C9JANlziC7NpPqXARx26GVffxHyNo9o5usbaquqZC349qtSDgL3QFsLrID6XMpuX/Pn9mJsyZ51cMHQs8+doyuIeCboWOW4NjrRY4iBzU51GUoina91n53Wuu5gO17KeVNAMgO1Itz+UsCLB/K31qksLJ/xVJxap//wXLhh48h13RXmoIlCCM08X+I4oLyGmzHucWV6R/X0lKCzJyvdkbfw18lMEf6gHoUS2jqPLHl05XTkjN3jzbLUGMz9NlDlT+0wp1iSbD/btEJvxWCIc8OsC+ZN7c2kLgmtx/0owD4L4je2NP3v3i2lLI6R98XQKFqi9UOE1Y/4vS+HGDd3/EpcImtOGv4uGzV+LBnBdALki3V102qTxkrl8+VemC2AHBjvopaIsAoQEsoYqPmRH7cXacjlndhkU4TEJNdo8f8sEmY7VE+yutkwkVCsYTDTTPJfQTOQ7Efvja+GfCd+9uMMVSGT/11ylX+r+jJPbEuqzA/IjFMkFcVh3USenM3/6UYcwM373H8w2X+u8OKxRWKjGHsyBH2NF5vfIqrCFNYOqHxAl3biTrZ6l9c67eqd1xLcCLYYz+/wcXy75Of2tUdLiu8yoj2LMGbtq5pwy0NYQU8Z941ix6sWcy/ZZywJLl7Od6mFbW45Qw4OOs2dY68hgdWBwZlk5VujPf6laAJPBa9p7DZRoLN9pofhbLkluCWC+Qde7NDbLDEHBseueh0Tm/5d2YfgLVvI11tjOQfXg9oK7lcZQt+T2TGsGm6V0AtwYaOWivflf1TA2CfiS6w94JGHgeXNz/29lG/BdrMEOAz6//CZ9IfPtP6eayit8j+4bMOTm9aIvxOMj6woVmv9Lf5z+l9KoX1MVW1kga2++5/+2BdwzAXaKdy1z+fEW7JQhCjbHEVBzFNcqKh6gPz0yaULiyTEJap97JtkQqdBMDG59H9ZtxjkHcVgOnrCundySyanXqjGkL2/aii5hWjIhPGaN62kJaUn5XitLcTyzY5OMD04TbC+uV7fgmPbVfM/KNBisEWNlPkJYamHPscXIXj+wZ643T0c7IPNvfrV8H0ivjgWUmyM21J67ZclqVTfOmyFhTLOnz1qHdyQ8C+RHS4+o0jLSH+f7ETmMWwt19mPl3j9/xXsoPEB8xPRlwsYusuQ8o9Gkdol9m7hiVk01XAlINeRQdHavHQFGz0fWJdq9fXt8IUW1tfEH1Cxx7AS8Bc9fqwjGL8XZKOlBS6VtlbffbVAE44Mu12yjDXaogWjazTCx2CK6LsBsBc6hlvL8/ton8Emj8jHoBDuQmMaalb39YTKpUcgnpMheznvhu5pGq4Ew8IQ1EnC7mkgkcMr1At2QoNV6KZYu68IGINN3092tGavtaXvkJDp+xH4rutK+F6wGDaGkONZIb2URcjHnCRQ79UePNn2SLp5KXORxMUkmoXDsXXJnTYOd+piuKbHmZ6x9cUMS9kHckwhVYXVgVxfr7/Bp3+h3MbcIyEUP/MFO40qavvq2Qj8y92lv9jXDy41YnEWmBcYL4bFiCkDcTVoYswCMl4j3984D89GwyeFUlSKrH5s2zSVfXh2+gXWskSTI2azINoBxwbOzM5mXwgsNJVCSFN08LLNwEWe3FM3NINt9gDWuNBEDxNTybmO86h5FJUfYZet34ZrBA0iBL17wZ5+EPqyj9WBaTwAZQlo70oQE9ouyraWTQSuvhglH+/eK0/ajA6vDCzkISMk0nbdaTo8oCNx9bvCFaGsj9JolFuY27qa/dxAcoWCLREK0GBse3pGzwlO7/DBdCLYZNnZE4EL2V0Ho7lpbQWmJjtN4EY8zthm2tgJuI76awLJXoumttAIaL5EJGdxGs0UnuPW9Jt1Omn9vVx2zD7fssjG6YtKwXmoI6mb7wu/MB6ivKvSEBEOREt/lMrX+TrHklW1BeUahCsjNZy7zXJEO75w77nmL54Rjv5isI/23X36Mzt42wtOHNTCrtFRc5DTDTrZi1/RRQwxzGs1BWqbC0vDg1vVpPkAKNmR2vt2NXCCF5dCpAUQNEaRNHgz2r0IVCECEy/2ma4fg48QrrYl0SE6bn/Su7z0zRgOO65QsAnNT+yM+5JckGNEVZ2uaX+cKPdeDMk4Iyq6Y/EQbpNd75/fDW1lJ20+rU2z+7coqaljHLwAwBGC9QefHDtFVNVy3iJP7U/3MgLKedhA9NRdJJboF2Gu5rP/h/G3mvLcWTZEvwlaPEIEIQgtBZv0CQIrYGvH3dmVp1zp3umO1eslVlRESDpbm629zZzM3gdTCzHEKVnyrtVZdrJtYC5xMyvDuzDGtqv05zNqLgXJasUstKNnzlAv90zTiIFgQyeN1xOOG0WAg9f/gRlfvoCJHB1rh7vxv/bPRYiRAJlTMETDvueCwunEqQtB/v9N4ZXAsCtuzr/ucEgxE9+ZcG7pjKreaEMMoTJe//QGuO8m2nITkTXxv0bjfDyaW/EfKsexujmpUJNJYsiHV7A+j3xQ0tC7iRz53sAo71bWEPJ+29cFIz4PArsS1388RKVz6/7D5t+tF3BLmvnuOFJ1J7Lur9eXpJawXiLf+HcohQCLB9LI+G9EnJT+jWe2djU4IDp8Uz3ZwiuPFXJapwnOQ1aNYqNhHLAA2jepFScqQdw0FuU282tWKEBeEnLJSBcu4+pmtDwdim1zs7SBF6twZ8IzoiJoKSC8c+90f9Srex/kfl/55PE4teHJhUOnqgcURrvQPAcJo7Dx4PvlLSUDaNxdlUb4PHPvXZVQ3REz8k+Iiv2mq3Tm224Q4WCIoVYbAhVsR883KdwwVMfF3AzRRPk37nx30fMM0yEUHvPIaTTzQ4fPeFMR9Kyr89jiaqbMb8ileuYLkLg63ja+LlHha0jqEXMCk+iHKw9dh+K56pM8THP+ft2VfeHZnDdPmt5inTE5xkDU68meVMtva0e5NkSBfM2ivYbrigqjRe58r/o8O9t+/90Wv1v7Roqo9JnHyNLgXdvbjkdpSs+koc/u9KY4cfZzMn+FUC8HOMufa4tO3oO3ODEqUq63eaEKIdOyFFjW1m/XP4zcedSDMGUWGPNh/HXM/EZPkzH4Iyo9bLzVY6T87SN5Hlz5cbchgwFntZvpSR0JG+uq/itFr+79F+qo2JUuuioer6cZ3QonNPKIn0T72HJPAuetX2LP9RIUc5hBUj9uD9oOls9tt1UUcoyYP0LAzDJt0Lez+FSSCNkU/od7r1ppw7pbYpg0IdT/6+W9L+sHhu5hxBa/baWESwU+yqwmpy//HT7aemdFx/h5S3rt6urp8o23X3eTbp3vWQ+x6ybBfbbif7ZSuu+YYmnWrxkcPSJzzKRLBhJB2N9w3tKzG7aw99p18Tz5plGJhorHq1GL7tYuHGTSuDuieUjTRBrNt5jbQIOZ1VSd6S47BArMYoR8aH18pMC4BUvksQvWRFUYpRmVOYkW9apY+gtmyPElgXJmzkvRkxKhkS8T6Q+/tQm7zXMT5qwdEswsVlHn0rmsFHz0/GZzCkKPx31tIY5+sH7P51NuKKkWvIvGL8S0k8siwcLJFA7AGnBtcEJeFhnAgZkfNuBXZ4LwsQLx+xqBENF1u8oORTTPnRBh9CnpqI7vsp9skbfqzmyJoKVCazbaWwvVFP6vBonw3s4fSx8G0b1+d2Q8j8DT34TNXUYBPnAjMjX75UhCJ+w6+R36w7M479VWB0KZnhG71/vR5v3A6VU3i9/0EBQiOtJgwVu8coorRhb6Zk/pj5N3Kx6i9PIj+QhvNPK0K3zc0+Pf9ahPyDYRXYC+jRVMJz/e5+mwP2edm3H1TzsztIqjgnWFH/Xz1IP38E8dSeTRADf/v/qOogQzd8RIV8Q6PPacnDcpGAfHP2Fe1E3AfiypirbuC46ekSBtbeN/Qa+6s8dj4et1s+7wbOmSqTdLWUkPiPCQdWV/7y037zG56sO7SQ6KF4ackAP05vA3MsE69i4DTsF+jFZIAKjxOXghSO0L3k6zekAWAtP0QVqsJOzDONNCMqT+JM9E5nb/+Rv/O85ZP7kdQFyQ1OKS6lUYf9PVgfP8ffFwO4CEk5oEYn/lOari+1jXJxFY2DVLtaN9h/lB98fUMEoEmovkq/0xjCEIM33ypt58V1G1wJEEm2wJ3nm3pHR5SibhSfHw/PfWamuK4AoCIGyI5hqOvpkIBJx5WXCZB3oY2EedUS4UDWC+Gk0kDYR6qxbHkmNbczlt4xzTHoSvVoEvUC0ePIwSuslLkpSe1L8NFw17l6DFyLrM1k8291MqiRJVH8lsPLi8+emJj/E1KD8qxh8pjqFXpGzbrHPSkvfYdcIHr/pPvWC5To9ydZgUQOfaA/p/8o+p4/Jd3ZeOaHhvheOA5/f9R0+kJuT5JQ/QNUNtBDOXntNRmzbjdBe899KJR7NYuJqIssAcciQ5X0KDuGYYgxWJPOSHZ+ek2Y6+v63EuEXYTz3FXKSP7bC7X6muaKiN2VP1fhGJkfM/pl0z3rJOPXS8R5MaaixwToRVyG/weneY7wazwkVQXjBuAbv8CCeJp9FjrVKPnaHBBVK03QwhdbYpr962In1xx7ioQTHRbEZOyWP4txR06/zqfiBr/8ohHKNLb9ksWD3xWF0aEIjf/wcpc2TIL6a4yGiH/t/jcj/y+ruWnSYszEdM6u9Gj3xDS6Z8gDaVDKlKD8mKAhkmmSorG2xiRnCfQiCBTIUPr1FIZibFHa9vUNgz1HQI7Z3h3vmgEBrN8Gtmiu1fsziVqO1mf70Xxv+dCNfyBQS8j/4hst55g4DDHo/l78ql+IYmBNjiYLA0PR9eiObdG/98Bqq8+PdSQbraUnj8wttcH2thWbljhckVzoURvd28UwoHCSIsjulz195IBvDCNouFVIjkrb9o7NwYgXv5N3wehEnDtYnVoj/4zn/9Qs2iBR64Fmuzq3MOtb3V8EdYs9Szk7nknuK7OO1znmHpe5YxecWImMI2Hm/sW3nK20k+gvEahJxjztqyDxgOklBEEX+OUp4JUWkJyTUByqYj/x7/DPZU3+yBhYde5t+uQfAlZDBr31DFSyvfz21pDxDU47nuSRtIhEMvAaDMJ11wC27sn4UDGPspsxJnwTiU9V01tfXyYiwFfK7eUA42r2kxsj7XoOVPHr4JKX1QTTXvxG7+eUIX5xWMnw6uTL5f4EFAZOBh20B3HzxMzbRnp/o6XMH2Wm3Zze/nuTJjgIkmL9RND9R9OlnEiIJC0l9GptKf/PCjiAhKlpXKgw977SBawTefZWuZ/6b/PELIBIAfiIBqIV+NF387KQ3DRX/9VEwWEIt52LpX9/HbinQ4rBZ4s/zjoOx8+7aSop7lVfjx2JE9phg+uR+PiZzzN8L0kRQ11MfceH2SdP1waHALB1FJFXuX4xpcidHek/ODypf/MevRe7MFXhTpMzOzH/6ZoOHFu2fqUayor8WSBN1vtfj+P/G8tJpiJ90f/Y9q13BGMfwLvrn2PrHZXimopsnMU6RPyFwDtWanVzsYK3mY6eWOBzKUJ/E+VbL6m26j7l5ui/xTVYpd8b/zhCXj0bxidcJ59mItZspM6wv2hjN3xF+svUrDy73jqAPVy5F2vkmWiWXFtHlGnKTzDcq0yyJ3dxWl7x06QGS77X7FVeLxwkqVlzO88FtXxfTbvRAp8R5Q4xbDn5W5hN5ZHOPtyy81i6Grz74ztcm1Afs6yaSCOljKNJ/8KTRHpmnl8ib9ocD/c2gFiN2e/zv67VVdGS5sv8tNNR+87vs8b+d92LoQhIVGQD63tGDoC8IHkWy6lrUHQRb0yvOmmvkYJ50LCH2uNp4FR/YmxruucMWiNOx2LgSvcIcao5JpCLb1cLf2/ZmopwbSiT79RuSad+KMsWCzdt571eZ6QXixvpsjK1/3otJBnQV99hevTe0wNHf4LRfkz7k1K3iTy5YtFpHl3ut1oU6vNZCf6whbglwtzC69Ukrh9kdoQXeJM7W9WHMjvPNof7Vi0nHdOte8yjUs/BamgMeHeqbQOTkkz1Ph6MHmSoIrxEjyRCHLQuf+ezTjfdtXSXjESp44bQmy9talCWFjtULMz3ye50JimFda9xsZGKZ0fMY3g7fUMO5vKpM/tf+C8UKRnm96I+jICXlJnq3wbbgsqVsMcOyY00TxHHjLxE8vt12S5RW8ChpYg4bAa6QIAGDiUXhkZi98enlF7/4dqOEpNqvVDyQblyR7oPLM/vJhna3Z1OG3N6cWLyY6a7iSaPR65iPPVkh/qSie8vuxNP04fejg1VE8MJGhnzL0Qdw1iQ/GzUe1Em7Xi0XEVSdOH1ULEVq1fb9kJ6fZChkfZltmQ0XYdigaIrVOiCBiBUU7Ec20c0XAZtLvpgtSJPm0dGIYU7tmwJBWtN7F5JIYUVCGDekxvg0ObFBoNfaCFHwHCPocjvHw52+VERP8cuGGOpbdZi5eniKwB4gbzzsA2NzT2jWqcwOeYWqT+OazDVNUgmDP5M8ALl3BDoaEOyiUTiUQFxRHdKLO0M/4ONakNVHAU0973wFhzyM9tDJx+/pN3A80rre3NXgj2qCjYR5BaUNPXy/lfc5KvyZ6kIxqF2OJuBbcSist0Sy7Rd7WzpGNR0dZUGBQf5Dymz+FIMLbZK8e+ZyMdrLVIW8QpDk2NRlfmoajfvt06QJdMOzPgTnfZoxTJLlZConEut7nKKAifW9rXnUA3yf/4P8IPjrfxMcKoOcZxrPyz/fZ0USkn4yCSKh7pqFB6QDWfDYPZdnltwkQxjmEyvM2Vcw92k97F75klqowjJhbd+MoUPT/DPkItf91aUWPycaQejek2c2MYeOfBdFyRm0IwoLVzvsO3uEq1gfKFBi3xXARTaiaG16Sd4aVp6ejJoygmDR4uzMu0lQUGG4nE11eFDm9V+o/iTUwbQjWH1wzikpo9twfowSyr6wMkPonpcQeKXqZ3T4WtsKwE+zRAWhgKp5WpvvkI5lBPiZmS9/Oc1BJw2fW6cpgpM3xcFHy9ULX6hEsZDwpiz34c7M25lMNYzPjad8ixx0Rs2+htbJcz0/BLUiIataxdCw7p6lvwasq7ga2AnfliP/aluubLAQ7po6eNI/mHQi3tjfa0BfJQ2xFQov5gwWSaE8Mq37xlaq/pxofyaxsVgo+Pbjbt8ieRcnHOZ2IRJhyx31X7B8QtpsZEM3hs4gVd0wTHiIJSwSJPYgw4+Zo1o9JH3UcH2ExaC6HKrZull55p5aGVJuzMh0wFOaZflkoWbvu0eXoK59KX/ARE8RlkWRpF5aWFQIiEWqVk33jRDyZVhCfb1N6OWDvPIE4Wb0o3tLyLxv22dVKPJXW63+rnGY/5gkTxE3R707kQ2vco8mRT9+PdLvj/g5Y0J24eF8s4HUwy7LFYD5o79eQ+PSz9doIQVwSqbJEJ9STtUo3r/ze4GTXOghcGtdSmqfMN3zuKBq6lKU1Aw3cCEtv50+gPHNp5G7w8SQUaiKW7QrJ7gW/HvsiLPt7Ni9A2F0Q8cB64J2QhuwKnk/ID60HD0O2tp/OA/jQGO6pakl5K/32AsNvWyIRA+/io25a4sPDZtTheloHMY4rPmvISs7fuQOBdZ2UXVKJWni0DnwApy7FVhtrtPGEsBaUSx+wwnXv3t6x290p5Dsle5cIi5QBMNRfe2jonwI6a06vIm+FDcNyynI1g+XaDq7mtQhaQ7sLNqVTFfxSwfLbQDrFz1EIAQYTamxqBTXRKohhA4B8dGDE7pyjuU9s1trq1z5ar8LyWvj9rEQPSNWvh37yUhW8Bwrgnb8CrEreG3AuBoWt943oK/N+lzHtZFXYgAgeoWmTVqRCZY1ggkNUYPwIpEL59dYZApov9kN1ArjfmLNao/DvzcNtlekNa3ewt7dJSa1Dx3e4bVHFm29IEhoJdyDRI/Zbb8JgI2zSGcyhw7mF0Kecthz+1THSHfvfzr1fstfH7pHkpDkqXfFtCYQ+A/gd7yl3KHlURQjH2yAka0s49tWlg9rPzzvvt9Ng2OYKdy73N/2Iuy/BC0MNxb5s1kkpLWdZddN0MUiz30/dt7/ut493OFfC56t97DVvZfL2rc/v3fSNMagDpf663AGZXASx5XYkC7l3Ws78O+LUBHCgwZH0vpCV82XD0FglGMkSSp9C/Iq9V696bIcOfYiwukYw7Ld901kNM/g2Xh4UIXAcRyK1EbX+LOgvQ7CV8G3aJov/dTqL+JLtxiWvVqi+nONWzSfFr2pmoYnNK2J8tdgwJm+bfxvz2SYsxHPqvx0Fof0DPyl6lF+6CYlknOkkd2dFZvX4WnM7EdPaY0/SSvfaXiq9e2fzyoafZ2/wls+sGFM2RV3qhMlUH377AB7wPQaK7xUjbEM4QdHdcevVFVu1Ml6TCwFD9at+UWP3ypvvfzQ7MwFlYJvJITy1fkp7BHHd2ORRROs5QW7vipTOiLX6mXiiWC/e+wfC93D6VbpPuVYxJoBvH2DzYf9Kn5dBUrxW3QeG7MJN65PT8Arr/0eI6s89u3ZgYgCA84N2QmfzEeBhPxIpeZWn9ocbvRwmBA5WJEOUxx04L+rW8YOC9XuhxVPgrK02NdCVbEF9F9f/gw4G5pVQJO1wVTzpj1VZxOE7sLr/GgYXIYhR1PUQvv5N85lxvPsxNNN1/yIKmMar6pmQ7Ox+N02qYbAVi7xdT1uhFWyYrrZWZnPzs/G5UzKbWxb3SD7NuqQSU+/lqW3BPDHCL8yn/PPNqe/rkIpssjtLWpZQlGvB6YPyJ9ZgMkHaf9Y+ajHljcg+ZOYqPdp6FOD4M9EQpfEgb6LIBSNYx+1N3IXNNIFgizYvy613HcZvN7Vnt79EbwTfdK1d1EUonREt1kUb70D0IbtmxXP3g+AwCmW0ZXfHLF72f4NJeLiFDmDoLYDuVtpSJtvmuaDI2Qm8STsrNxJO6YOJrbfl+FgogVdCY7E8C9Y71pZBWuCs8Qw3ut7KEPbXcmzP3Y4jQXgI8frpeQdOIBjZa6H0SOankL3hX1OgG/ZwB+v9+ucwh+43DRNLp/ZMni86ViD3fxGM93T4vxdK0TlFcEU4Tyh9SUESNN6iF0t5dL9GsdvF2wiAMgVW73fgHFZdB//roRNyPl9dddj2DYxxd0wCbfumn0hDBRnEqazFdG+w9onCdAr6utzlcFykzpznSLopdx5Yl39+c0aJrzVDiJKQUPG2/UF+sHAQh1SkVG4PUnzMNc3qbShd6SRWysbqvRtMeeFqa4fy3VOhX0CHEDRr0oWIm2Wccu3frqUrVGIJ4NPnr7yf056OEbbF4O+o25WLzm4P5Col+isQWkBi1AzJKkbjoISISzckapiKPhfvbRLm8Fgs9FJ2+N3dR5PW4Rog31t2AsuqKka5EaIYCVNVe1vhvVD1GASeLqRR7XmCuWhD6R8/e6E80H/63SQPvIzySjyZWuSZHv55lzFrAsK1OWVfdeC15I50koIqvzKONh/NgGsAnZU2x0CDpXhy3YevzRKniyw7edhkQuW2bPewmIGsbLaq2DBH2DRrRqvcO7DUO1uSxM+bRQ0htEPTOsdOqkB0FGIxFQRMh/mGXdTOjDvZqb/mMq283mOw1sUG0XT6GTHqjKSv7buyBTxv0kRbU3pAmfThrEM3HRnK0YR1j+9WySrTtqQLHLXIKjUjzyzF3o+Byy8C4KT4nsWVVtg9X93CsZjvM+n3YmTjPSRLkraxST9vnnV1tt91TfNEiJqDunneVeXCpik9/Xnh/xy5mWspSMJySHgM1hOpSrZAEDyfnw0weW8GMe261RX4UA/cfzkeGUUB30Gkf29DDTU/c/iVVi4CbkFqVEPcUqKzHNCsW0R9ETWVuwe5t2LwWdZVYsNNP+FfNtgPkx9YBDZ9/zsa7ynCHcwjZ2cuP1N9sxPY+fRN7rK5UwHgDgKDtutx7T742jr4UN5c8Ado2uum0dznXNDrnZLrqKFONg4drwJcI+RfRE7EZNzb81wA9FCgmiJhWgihDXCscxQtZZPkxZDCJw+AVOFDZz8hYyUebUx+vtan5D+sO8ZXuoEcfFGUPodUxSZezaFXW8WXdTZyHLd2bflFmbqxegBvDMDvaVOfPpnSFwHPM8vXYJVUf92BllT3X4UPzQ+x1ejD9ov2qD6V5xCLVigtahsYnwZhsk9Tn303thgcqRBn1KFAjBPTJb7afjSslc9dNXA+4tU6Yeh7o/uzyBT3vTfGrmsJ8HEorMGwybv+/4bC5b8wThrDP38ePVn9e2Nm+yyrtzg584BfOIS4vwrJHnKtxuF7MXEuY1oevMQOUG34bJxyjPJmo/LG0POuT7f+fbIMQh9KA309lKb+mLJbzUP1rtb3WddrHqtyK5gCxXrrKYO6FhR8fD9/Lp1rtQ60ihL0dOeTlA84gMU9v3FZmwOpjksw21NbnRGK2n6hQDbcEUvcxwQfBRy/s084gfq3xDTzTvz9udgsIkWBejLe2MAROG/pn/R33WGqUBhYD5cviR/8J8IzmOCgh8DcJ3i/avf17WoMOYLV4NQOKL5nHLrFiYGa0W8byxIekJYXlkm5iPXng1HbJ+Hhu7k4/KE1zyGBFan9rdnyFSUJPEVtL1ekJ36hs1kQCRf5ydFkAn3dhRO1nBIYFu2Y2HTH3bBgZFzqUbzyFIH9UN6n55B3xMl3xy8ry5Ow9WnH1lmWZPtXj7P+hCSOGQQyMGNMUvQ803f4XPIgiBFsiCqL/tW7d47vZbU+nQcizaTJpeLOzcbPc7iJ4KuhVsQAo12XdoKjXu+OIk9dAnTheM3j3mq0wD9loi73tXG8NJlhWblzzGDZF22x1z/fA5NH2b4NMmBg2azEsNdIdYKHHFOlqL9NTLp1MBDOZUVytI2kzATo+duJ5hHIGezbMQksoQmbKVdzgP1OW5TmBjyXeeG5wy54aC27HNVydcPsC6WDacuGXLvMM+mp/GNZYyq3U9qxLiv/RKWygJ04M/+0jonCMc1N58RQGQAD4TPltvCCRnCDMc2iynnlmrwSS1klfIAMQzLojH8+PS//ltJ26JIOIwnjvdf1rqtwmSpKmcIUnHomjJImqbQ74HT0dwi7ygfBU4AcL0tytKNGJkwm5BOlkeZc/GRi3PIpfjfiRq3P7UDM9wq4D89hTxJuagttkqsuqp28Cmr1oi1VxHK3yjhXsJEHds4k9wL7vFqo9xY80ViwLEu4Dxk39KMjABZcLq5ALzRFNl/ZcaOeltZ+yHPCbG6vFF1r3O89w59pr6fyM6kbR4Pws26r0A/WPlx8TtnI+3WTOri4jPb5XImI+twWU5q5+t2p9/PgXw2s2H9Rl3DCisTvdTdDzbSxgfQbUFNPTn9ZO6j+ACKnnDQAnpqqKIhIKzeeXzx8VXke9PNDQTfWf4FbPZpTvlWOa4QkmQfxPsdvVWFRHtWcfB5SGfUF25sGU0/HHH8+30A9NHwrB2xB1GR7XQQmrxuyD29rn0lvwuX15TF1fxwY1hajMhYzdMFZxCejsNgTyk4CrkRdXih6PMaEHmP+Nj8ebGwvRmq4J8pP6SWKRwvGQLQ2Wmw9Mf/zv6xhWRWbJgAWwb/RyvBMpr+Z3rF/uS8udhwZzJ/fRpDMWesn8JCE3s0fonKKb5hlsD5Kjy9wB4MIuFUGTrjDPsLXTR+7u5e7dt2G6EoRUteJElCRIErfk+joPz2eh2tvOQ5c5xENUVRP4y2anOLPZzmcyI3GLCvT25mGU0lVnUfB9t3d7KBf/ymx+s2vyr4PRjM2QjbYMkyLLIaMZMXXIlJzsGEctbTDDq71mWHD1qEepeqcxByDbyS/9S+Kp49FViizcjS2/voO4cyOLEfK38TQY+M/lWUErkBRnEI3KvgkTz7zXzVzfPXo+xEn9OjinuhCAJ+ec6fa2Ir6OgJy3p3lQHfZzrFRogOLtWKMjt21uyuWVLnAMDm4ejyDNQDYacYLqTI52ZUQ3q9tflZztooRwaK4FU0brYSc0dRh1l9hXZ7CPc4AXtEZ+ZVbkLL7s487rZcWPCqj5go+xz9Jucez0xhYxy55uRICniSe/Cu1vvX1OGwkfunBlJBbQmF0nxPyjoCCTe+S60dkrXHI9osslM9seRPM2Aen2eEKjEtiXpdK3WvniQcwvm0xwnS8Y1tp1mcrnAt9TLdmjKzbC7C3DANa6SbUgnW5fVexLP5nqOqW6K7j9jYpYgFuXIejfvQlxxmG7ylTJTjr4d0mYeD/p1vUkZH1EftNfqsm2kphEZsWrvPQP7N2GQiYVkNw3wQ1bYBdsZYDA73qzWcvC/LbVs5uVbXx+GNeHGyphXHv8Ev4gdNWw2/72ZjIZVyLUtE4dTFpKqqQsQJIcuzd9dt28PddNh/fS/lQ5F5Hz1ZhtGf795ZAaiXIo+7zHx/CDDqnPXHsiQJIgWEGE+SHPu3xAvzCnD2XHFZJ/hmq4iBP4i6AxDw/uOsCPmE7IVDUa0OOvXERvNRjq7+KP3py3yS+aptVZVhIVbsw5ZL4vjJ8g0KsqpwfU5At++R9SnsIHr/Q6vnMyOxdoKH0ugPjHm6C+elCvHc6kmX5WoiY+HdWFoRzHTa87N//0SA4w123sJj87usAjz4T/AkPJk3dBErM0uxE4pu9YZVaE8uDIU+gP85fdIn7nrFWpECAUDAEhip7Vl40hW5JGNPm8p0E0LJavxzeWu1BR1rwyMlPddXgn5NzKmkHZon/n1hPHXeU6u9mKhutyJhLhy6skvn70xPZc0vQfSxqFRn96eBg091gSguRy19ls/fiBT+NlDSg5oWZIblUTx32dLwvfrGGHXf/p/wKqtpuwSD2fDfUkadTRiIuBX8jRIWB411w+CxauDtQjv4FxEcanMYvEMSO4fky77fd8x4K0abH2iMRZJlBy6za/sbZnX+9Msx4Ica8MAexN+1/+zVNMGNxmiZ4dxNeZ3yvQD3+Ai76MNKBPRiVTN6OJMiW1pct2VZL51lAArAIuCindK0Kk1RjufQhijJGqLMMwI6voDdb9WL/CMsAQ+82diHP1gTgMiDtF9/TpAHFVGzZAk38KKItoRabZ4DE3xC0fD6vpeth5F/IB+khUmVuoMrpC2c/Wk2aNXw87IMHaVoHbKAUXtHSJUjZ0RvpgyNXldWXAHwbY6Z1qrqco/ebAhKekpWD1j9cyAQSvYSZq+gZAgwJHSPG/P9BHb23vwK0B9mAWBx08gbae+8ea51aH8Zs/FmrMIBprl96qEfhfid2vKSYohuPoYsinZ2MyYc2Shi7GEYbdc9bwBd1/qiX05Ez2FU83KL0qyvJSSG7q9LOwYFYZdKaIQlbSfAhSakMQe/8p7VAzhZ9ZSnBxugwtYvpYB0L2XZzVcVWRa7weJTZX/N9QNxU7jP5ocdqTG8bibCXFHSGFqMuTmRsRWXIGmZHmFu3yY/4YbFbN8vx9zExFrKSlH9ivlHpc5LjB/601VfKvc7EbZfHNY9vaOybDuUzVHTueRrOU7lMKXxWHUr2Ej1c3rZDAV4qsJZ/7uX2sCY4xdAThsEDMgH52rmqI2MMZ0MOTU8tMU1zdD3GYVGxH+0Vn4tReXdohUcOHMgc73E0S+d4vXM8VO5oAAmEHNmIzQ0aft9Ez6nE4QybRz3Paa7h+gAGN3y+WzbRzW+SakhUcK3WMn+K+luevVoCzpnwMucZSAmquhfACRD9HA+yisUUuEQny6cZpH6UH9884ZJqdGrIf99iAmth1eGQGVep2KY931TtAaXGXDMcWA31/N+hsVhh/iCA1B57Hfv+4LCjLk+AemuNOS5m1bysOue0LHl6b0eOpKk/vHgv3n2xIPUQJSTSASO5/YjNe6TEZhMw3VCQB0lEhJ+KA/SOU/I+jzbNyjMRUk9I58t+fJ37Hps6CDf16zXTRmlBw9wafEduI/pKmNWtPYjKLjO15s5va0mTaBDaNovwCnZxFBp/0CuJAvufn1n2KIj3+BoLZJFznWHzRfQI5ecdNnUIzNCSiINRBfC4Rt5qOu+7sUhTlOb69l4iySGCQPyBY7EvockJ0DQkBx6WwVyDvMskb8dTbmhZ7LDG/ValLpKfnh+EYby7ydDHZJDYdq0OmI3Mbf5mpDH+B7J1pEFhcYokohWGjngTffq0zGsxNWJLMS0OW4p4K/Ae4fjUajswhJ0tYJ9M5K3V1O6xNuYttX0s8iFOlV0FdPKi+eGL6MP84y5f2xS9/djmio+rQQliOuq/LzftCrLOEFwtLI8gLcURUTm4XyDqOgmtNyFz0qTm8kwDEHH6XONoRoLnNowVfQMH4tXKwawA5PbnlRtH+bXYB58PWRu/I3CeKxOKj7TAIr/dSqTW8Gbzh3LPPLL7XLFSSwciHQ15t8BVtjBox573os7+f0miNCMn3XHWL3xat8+Y4PNmLePTdAKDM+tEi3vru2Vt4d3A5oLQyQEu0inDeTg2PqUKUw12qfA5EsDg7PzdSLp6Q1rXTQ8BgJTxlMoAP7JZaD47BOP304b8w5YcIvgnJo633xO6aIQ/HbPnbcF33OdahVqF/pvbwM6kIoyBARYLXyyKPXmnUbevqbf+bTUnfdLcHj70BmeD2FA8UBezcjFoychwQp8unkSgBAUaaertvQAQA8430U3oeP16AeRx7KENnuIXDctGz/8XVMmjaq0BcMFz47+7q5bW9lj/u7hTa0neWGPy/xVI0phefeP0stR49LvJlXf0XaLw6MduBZ5fF+XWdTtetrj4U1Ha09wH9p49j7B9/E4X9BH5bkXcg8Y4hSmcV9/KhC0N6fkasxHz3AvWeq6IoSOn5oCjezh1Dmd/AZxk0v1yB12YkQHcC+IDkSjQD4gPnN0CLCB9hJ/PQLBq7L5HhfUO+IATgccLKWpMx4L43h030RWMVyL/HeR4U/4/7IAOR44+s01W5MmAJrXulwKanTv/PezdBS/2ehTouVjKAIp2L5vewB7r3UftVym2uxexwptFJf4ax0BVVdWQJbGM8P0vk4f60TBKYrFtjDHZfIrnAzPE2liDfsXMLoWxc3ZJbcHA/ylgM/v+c3sN7yXzdPQpz7YAW8a5kKQvGAqQ5JLFmJ4sgq5t7R2dQGBI82P++byGP6Fh6j9ejTdR1CGDnj51/lxr07JXRwTrX+7FcOvL6rZAiGDlcva+d4DJAa+tdJcvQXU/jm+BE4neUdx64Lf5aeAHqcm440/EZupopG8HgCl2jKHRMzfWW30wjZhttD+Xhz5b4hzY0Oo6HfUJ3NgQg8lyoVuDV5VooNIZBM1MaiSujHx4LrpxqFmyncp7BbpJdhiEpOpjdtBri8LETjEM+CHUYoFzYZsuYZCUFf1ongJWf3+k99iuXjgNEHhI26jVX8FiKAQHVK67l17jcd2ZdRLCPZHccrQb3l54ziyT7L7oziNSa434L2eL8t0hNtKe4Azp7cauS6NbfyAfMZ6HFtP9p4ENrG03LPj7V/VB/uph8c3ljn01vfY6OC4OKsJlxyhWOIB5YC3UUR1qlZcWAy0RPCtXDsrVz+CvZh9q05MPhlUvfYPz5FTzxstitNlF0DQBbaC8teaf6v2DTYMcb4Kxj1l++A4heNfzlP0cTrPwZEXhssQSe4T2prvDqLFSbH3mqO36HMIEiad30TWM7ZhoiaQb1MNKYak3zwSTlUbmDcvqtGhJQWN26pU+7l+f9JywrKqogeBhSBdcg7UFGZa2r5gvZXHEhuoCE6kVZPnaWiZ3hDhN9LipVu17wrbLDKSEaibGfZfrYgBhq/o3gnngh9NxQWxD+uQLXF+sa8RQLztk0y+3fWLXdZo3BfCfDLaASFEm5EvYDyGMRLX3Ckk+jWadoiCtNMMmd1zwWkFT8h2tTMl/iwQAcetiMXXzhPjor07NjPYTSbo2XRqHXoQ856ibt2kdVnOIWfjqbGvbQX7FB5xIOaPei7yMZxlZa/QmySFV/jxCuTO3lmlwWsz1E0GZIuOKQDqh6iuoTQNhRycu5cTZuvAksTJ/nPSxoSrnL97V41f+mX3LSHwSN625LWstT987UfFO5+S/x6fzBaf0sgdixE2bcVI79PUAc2gAouL+7juEL9WJAfdSex1j6obcBH1efnxjx/18/M3ONn03Q+PLTDW090zlV9YQct4T7m+TZ3ASo7d/LQ+Jail24JVpYpVQ1HLdk+AygFG2CjE6tZEA0+k5wbrNOiJ1me55dvuumCNRgyrIp9o+tDGzPBdBFO8IBXD0FldaMOzfkwfXXLANjSM7AzjcMlMgWXzMUMlJUuFsxCEc5EOnivMmmaUEedw5RJeySOypQvwfJ3Y+p29tjuRL1wtNvYwmT56VqSsfWGCkEIrc/ZIo9wjWUh3gZokJE6R0qwRQJg+HpPHdYTOJHFmQXullgpLxxayrLTowWVlmHuFClMOuXGxwWczwNv2wYf+oUWdwDobZWkEnLqde87cU6l7m2TNCEpR2OttqtXlA0gg1VeDvQincRWJp7XkQvg/59XrXBvsEcRdY5pty/f7D2YQHdTedxLVwckxf1gBhaeWNr29ZJjddj5W+DoKXUwWf5KJwHP1pqaqaB/LeqQ+uanJitngRSx75dLWlFGgJ+21P2b1kQMziuYXYTYInVmzmnv3zCXyHG8T+gyuQogJrPdXve/J4Ak9HT3IgFd/2bJfYRYf4A08wBxLY8cxQ2D0MtY5t3CywfCwtBO5uCnKMmsVxF+xsAlFRhE1Fhf6a8TvUmMBb0GETzjxoottyvb4TUdocYp6M+fje+dEstTf7RcJbsDkFsYT535BiJNOEavHLHtvV+OgwpN/HLxLcehzFCxIV9HmurZ8Go7i+ZSbQuXq8H3YxO778a9t53gJ/SLx2KGL6jk8+099D8qfs1ejr8PkB5vQsCxRXds6r8t8gCdJX3XKb4Sun3FdzMAfGB8QV22weFYgE86xtE9zAX5C1+5hDMz66r9jBnxvjCFKes8vcMrFI5ZNDLO6d6qdgJZkNaQbIo+t3xJOd+BncFgSOaRqW1rIy9LZKTdcanqOxetRdODZBqSAQ4j7JHqawlBvEC9On0d7nkGNmeUSOZ9p9UlS1bIgFQZ8Sd78aB8HZt49Jabr3EceW8pt7LcpgmfwfkwFFctWI34arKzhQs59a+XOuifb29/4ot9Lm5IEDwWHkBPuqxUxzVJBrHD/YaG/FAj48vyVs+qq25Ip3E02cqDnYVYFNnhQuViy+pYLHo+B//oX0z5rrHd1U4ABmMBdn9+fucaJ2sGrB68NY53jGb5MZigDhrVv6V30aUsvQnOiCGUW1BOdkdamQlskHuC4vIGVFWm1imV92P7Litduv7UcQkHlkKd3OSow8mBL2BksCeFGJ+5VZHh/fH43FV7RBd4QB2MY2jH2nRDG7eId/UC1OazEaR1rdwMx+4l9LjcsHgARHll8mMa4VsUDyWaF/fae512v+3/u6BdrPm6Yf/oO8H5pLALV35D+KqBVBTmv61ROxYAj/mQtZjMxsfOICMcPDDKJtOcRowl3KyqN366CJ/JskMOYxPklOm2NLOIYwTJkWbQUc5Jl4lW0zX++Elp6r9/df8h/CJR09s47GQZeuTg3pTUrrClNi344T/gqzDx/1X+9ktt9CZ4Runv3HoUcoZjFc5eJfqzoaB2Hry8uUd6zrTgXYVxI7TnK55C4/WN7An11Hizci8Ljr3WkdgPxPeUdGkk5ySO39zfAtyBareuekdnUvhQ5PQzgvTqqaHLZxvK9KCbSAKAc+J49plU0G43X+QhBxJmwBY989KXhUrzePvDAKItojqk3TRqkqBNl7dKgTY+qYSQoZAgshu5ahbmx8UC1aXBb5Bsdn/8DJ9rwinmtOwUWoXgLmTrzt3R1lfsIIuLOwlNoGuu4fBuqj4BBb/B2sXhFAXJG4nmXeFYW4TepgrWVJUGIs1/KdJl0vnp4RmOfG5a8dmaAijupzjeB41FwvQBgAg5+KmjseP3ReM4f8l4Hudvd9BtGFLABhBFJl3s1r0+oeVvI7MevkTtN7wtV/Y1DmiJTr5A/VpmAfu8gR/fLY4JjJvw3N5opSVwSm8ePbQOknwGup1I7jkOW8yuXgsrETbxOef67NgumBt6ij6MUgGfi6+8ea05dvkkclzCweMgj+PwCEcfLqo5dceu/dYg/8qzDI3dn/jceIxVnyTVvDFbsN6oYIrJ7RW9EctjVe1+7RYWrJb3vIqVYVWwvuIrGlziSVlO0JCGBLxUKoWJr5K5O8pVgAuyuyBdhi8avt02qLrVp10BrNLkjH5+i5E+aAwfCc5pUB7Q/IA5twQJ3EX8HgYJRJBy0IeLOMFzBh/9PXNcUqXu5j4oDXt/zAfm8lp9VJ6mNxM6R7ld8gPf+5oTHbIsUgk1fKZ0tFUYwdobHALIpXUOewCZNDTF75Em8Li35cKv1UwiKNfmQ9DCB8wG2BNg5qU2lCXwOj1rRQBV9/SfirwYLI35PODDif9JoXzXNL0DE14njV4aw/ugcAiIPIb2n2ftN2DUAV80BV72YBHixfU/LcMD/m6s25gm8i4/tKFU6cpH/4Ufipnuf1Hujq/XIgUc3p4G5DVOsH6XdP41uz+w04W2RHsGmyAwbQpfzu3s+0CG3MCYHonfNohW/7khf4Wr5a6F700Entq+ghXXbWw9wmfcZoE2BMPD8NxJIcc1V3xyw5aKuePYin64u1JT/dT0nFfgY+fTG9+Ee/t/Em/bqlg6mV4+fH3Ofz0et6vs+xzMsuFivlXmc9UGzkcQ+Lrg1sj+9R6r031XTAl7vZDQs1Nnvg+MN78lecEGP0cjV/mH8vBT9UYgS6qaeSeueRtf0wLCWYT/MbG2oSkzbHJOC1Ol9V4u68WXuocKA2A+czXRdWpraEEUXxlhMTe8AFL14xL8oDVAZc1z3EWG6gD6FCVAZFBi4WxQjOXnbi4S5DfQbBNRHM6hiiJQiCmEiZWWmmACvgkdWCNVe7Ga7G0NTWFq1yCR/INoBsCOUftlBRoRuYqksezeZpqeyFaoDYs0zsMFrebLHU06yYtbAT1Il9l4zA33NvjuR1Mmmh+iC2LNOhGUufyN/lEgcOBaAj2XtJ0pnGeDIFFhWuUoVsyy2yOuC6cUWCVgP/6TdP6fpKb00Hpz0BWD/zV5gfYTT1t6nRQAbAJhlbH26T7bulfKneDHbVn9MfCY7AAyeyLq5/LcgyXbFMsive6+FJo5MX+SxOCC2sM0yA5NG+if4fSik3Wnh+fWeZ9wMzh8WngwyJzrkkGEWOKFuwJABnJUbCgPpowhDdCJUHG/pfYGgMboi079MKag+OYHRr9j3ZBA1zM+Dnnt4+yzb7pe7mFDr+jZjnngJ/g+HCfoTjSHmoJ32nXG/vJXlNq+9WwN0ureRj7LZAu9lI8v9ftFsScPxB+DAqhf9ot9QgygDiCimFKUwQ2MQA9bZ7lsjWBWm025Sr+YLNuTgbfd78CzjQWfKxHBRE4j37/GmkDOLlZ/CTXQtvI4a//FtY1098qY/TGgeonDfxgjomtSIHrJJsExo9zlmfjFSM3lMTy7HH4Vh4WxIN8ImZ5EAhKnrkn7oZEN3L8qU6zkAqMxhZjuKsRU99WaNm4Cq0ob73/GWJvVe68SgOPKs8/7xP3nLita3QRr4f1iLVN9Z1hgF0f83q3zyzgCQtSlNQrX/N6sstzW/pnaPohVlF/fai0GMUVux39/cy1oMBDUck9IiwXmEpCytpgjuK3uxf/yUSPPtcsYaHiESUKs5XT0eNWS1czaGHTZL3ej8ht0MBq6ukypy1q15wyaiPBmT80/KGv94M8hUMP7zz2qfDw15e4EKOcc7V9uvZ73wNJOaQfuMMVrhogMHD/gACIAvxXChwv40H2yHuf9hibIzPqZeZ02Cl4B/Ar6kiaH1FAh4iRuFxBJA65cjzE8QrR4MBcL4oI8kwBbFMzTWh7RQV2RyPyavJZPkCQqB4a93Vgo7LDmgi/IL+ccacLWR3F2ZAMyVtqg/uoQrStDd10ScFfG6jff4ARSMZ886z4KnODefmf+WLNE1YpCO95X7WIpu3JgO3N7UBUDtwkQDplSEAaeZiF60Iy3hlkCxOIM/QvaKF091KM47UopK/Oow/z94R7HGS5e2o7wb9/KC6qpvJSvvPcC3ePs/MeU1NIcbRjtq/29ZBUIj3vH/wSp4y15b6x9WQcjxkU0aWO5fZScUk+X2xstdgYIZo9Aes4ZztpwTq/GLOiGCNHwjHO36RYwbsD5RxIezOhjj6JqAC8DpvV+dEki/PK0ZeJEswu1Voi4reTEMyZJ8h/1Hd1wopnTLqv6fWvO99THioYRxPy7hH/sXfqGY2qDIZMxGT7x3y4PcDPLCicbWEnjyHJ1y2d3vx2aKa5GBzx3UqnWq6wMj433GmqMkC/QlQP9xqR02HWpzJRGPyN02XX+xqs0P7hf6TB5YFtIPxY24DA+exTJjQ1g0nmfX/j2+tsbPlACtunguP4G/kQxGiQ5T4S1ICBNAl/MQD8KVBL5NjZLdxFZa+bQrQHu9fzL7NIvP4V0Jb5gebVJimpdZzGsuxvlc8CalqVVfw8cmy+ev6LBueMKsK5bowZv/VvOTho2Ev49/s2vipz1wbvrlXLLvWbg8uef2Cdld5HFaHzrHyunqsMUfWMmAT61mnPBn+7P4CGT5ogNCfaN+ElKu+Kj7HKGHx3tSB22CDLJa8ZuY4ygdVeKLoAm38KaEEZQpfzn74iW0WmJ/1nZ3GdAiSsjPT0MpZMTxJYfY56H3Rnr3ZxJNq2CSqtBH3aKdzlFGkejj278qC+j+VcV6wUBoAkZipgiKbu4fRtcB3MnqlLXHevt9DRCDhuzTjSeJn1OCXyBF4PtjxlaPFci19qiXzfnT8s0LHuKLmHgPxFlzarLwSE8na+n/EkLGWgaambC+a8vsaz/hfrzTokKvcsPc+pFkCPeVHKy6acAf72+2gaiHPofmx6yrrhLVEZN8pNEAUkXpNJ3VPDQFlDxXY6cGqSsS+YFM6NRsvZbIs2xO/rJLzrXOI4oZaUD0a1TDSs82G5XfUFmYE52sNN6hTDUpR/ErdgqNo7AmCi0GP+qXq7QfzePzGFUOA4cDW9cYba1/+ESJwkIAIpNIg1YlDCfZ4p6Qj8aaG3VO8EXokSQqYcPNw9EUlxBOOYqZ2pIINt1huhF4UwbeI4JLhy5/8yhfBA5Yk4jGYPTS6cYHRd+SCXl18zFXJRJHUkJK49km4HAWzAjw7vs1AIIGe4iJb0PbPqHejK5vAfBoAI9M1lVTbclvSK+sKYTa8EjWwdd/kMNgO59fopHwP3CQ3FHIqVMeGfq4+P8g0ttNvY+4Qm6/XrE5Phjym5yVwP0PVs3ZgZOf4n+xajrE5Zr+ysf/YNWqASIUl/uOj4o+j0B9ai8BGkNb742NjwX4BW5YP+NaY8FafZmzSecjALgBMWaYJ5tCWKySnbD8O+GYPElp9bfClxCeeMWWVuuzKYp9eYclJO71bxzwACYpkwfhPX+YZ8QPtD9o9+IHRwGhdvQtM7CQleMXY8hVRZerDury9ULRr0RAGwU9FUuIXUv8VvI6PUthmIYcT7q1ot8xUayPNzSJ1XBmiKHXCaIxY+y9UtBcwI9xrGfL/ng+rkEGnGjy5vRgWWIAKHpRmhWWVLI6vXw3yMy6Qu7Hc+Ccv9U98IE/vzoi2oTc2vBMJ8Mti0oPqBNgg502Pm2InO/MhrU5Hg2cz6M0LCwh/s3/uxlgbh0OmJun/TfvNjeEQZP/mf9fpgXw/99tn66sNNaHOVJ228qphLZt9OuGZHtsXQIg6T8Vz8yb/zdnEHzCnD0ToOPisbkarJtm/h/G/mvZdWvXAkW/5rwzh0dmijmnN2aKpJjE/PWXXXN6La9d51YdV9mzbI8hMXQArQENgG88/uMqT63Ii+UGZ3Fl3wT2B788eINhlEPhHQ9bis6kWOzsZPdiAlC3XcoGdCqAeifBKE0ZKttrnBvFPH8cMLLtGrUfiGJ0dZbuBAHJj/PLM9MAcwLE0ATZcavWqsqO3bdImX9qTzOvbk2mvF0W0SXi/ZeUfpEEyL1ceMoVb/5KUSQ+rIrLfC1BfIrLnf8+dR8oiJ3+iY3U2krWt4quZu/B8yrgujCIy/2BZnP083gB7BVyCPwi6IfaQNUDxtpbarci4WM8FeFwAlfg6KicVe9o8DkICcLo/x+LCLjggQPEbwniARorxEEQA4u1lnBHlqApQhT/IY2mAoeI981r6Hpe9Lh6AZVE8ne39/4birG1yqSUwRBu++zD0RoMVv+FyScKvGcyoJkEhdLPm8xPDxQA5MdnIhbwXwPa/MWtBJtyOfsVjd76sV8Z/Qo/fg3YVVmPel9zCNLM3WDMP3FylQlTg3JpbXiHarhDop+qJUvmQa6CDqrHGZVc+YWMvSDQL6l53pc4rWSkzFliMiSYS/l4jnsjp8fbF0N50uB2u//3THs5ZC5deDr/4CYHOlwY1Oi9BflYBAXSsRUxuVCeyArwDlUU082/kdP4xs02BfnA6DWZkYf+fSe/bGD/uHSwc7koBw85MxXg+iaBgNqHZtfytwW6+QxhcRR/FbIMn6APHOTMuyrpFKfg38jKEIVTsWEKkaOsdgN7O1O5gW5yRFqoeFvuQaKPs7aMWOjp7m8dd6tdRrt0p4b4C3zIzdKqOqllez+g90Hzot/nPGAajwPP+LgJApYrWi4/X0zzoOvjsvJMFcZTIw4hU01RzaLg+k8FyTwGocP/QYIYKFh9scnlfsXAx7q/Qzw80HzR0CEtI6Cie5jBZnJrSHhc7vbj41GK/VcMjEzS5HHsLMSNwMwPnTznP8VB1yn7hG30j6J+f1BUYS0IUvk+NvThY0WhE0QPWus80Ksnl99tX3OR1P7NI4aiCjvpwUEk+iasG6RxhlffIvDz+waZPE4BWNVmgEpjgGVmTZgXjLx5+i18l6EoLGp5K9Xbnt9giz1Le8+VlRuYXMMe32SinIHhV/axcIJVOechLLCaet+vOws5dwOo7wb1c01q4+R2UoCKqRnahu/qfJOOvi1GAnJjAGqIiPspRGZUDppkQY4DeXesFhllU5Ksa0nMeKMwpmhLhQ/F/knudc8e8sG27Ik+8JzouiuvEFiPPtQ5/Y3oUvNEFsKRa12ATfsulvRP3dM8a9QPDtoihgJJUUtjfhLeG4goQR4aqe3tCsp1u5+goqvHrMV6ej3eBudf/n+ruiF8JA/79t5pZBma1pm6hQSk1Nzr2uJj2FZAVLZtbhm9in/lR6jup+G2r/+bIflsBshHaoaldOYDvRJZRG3rsSVwWSTUfMKqa3l7YP5UoLMZQdddNH5VdcOriZmNM1mFQlnx9294/HJCctogq82Xd5gn+L3XuA3wnNLOz2csZmq2RSC8TR/m2XN+vf+EU4fMu5dCH0TJbn1O9F/bJV42eP1Sx1Rm/MQf8GOcdfN2W1Yl3quxON8H8fzapFqf4ly5MI2CZnlhLZ4xuzAxZPaeqY8JAv/jMoJC/Wo4aiA0qbZ9Q3woGYTzzvv2rBn8t3IoCjPxuR/EL8R1tWij2jJGmKHDm3yFvVlM25ohpAp6Ru6Hriemdgd2/3oZQP2QRwML1A+NqQD1w9FBm+7eI5sju3Gc/1SeovaFfYa29S6W/N/Kk4C09TvMm39XnrrRxNHb/yy18/nwaAByAqDMvgxnCO23xQH0aKR3V3MHRILsTzg5vUSkcrXO8dnkpg7eE7lE/b5LxrZRjxWP3ExhYX/ek7+iC0zhAOVMENJidTQIQLT5EgwZaZOsvPHhjnFuxMaq+23rWEahFPK3THtU/uusfmIfQa6WX15R+ph1RdeAjz/8wqhc8eNr36j8o8Dk3NroluqBPmPksiQLrXXQPGhPnE+cFyTd5hMfDFYBD6SedGL/gADNCrj5+NX8VGpf5PuDYfmzthJa3ZTHg0ADLt4POhYMTubwV8sCrQRd2NdEjyEXNi4bwTWoT3kUWhPS3ZHLGirYH0ZPRkW4GMpkvkLoV3dCCOLzhF1KMiJQ1fjZ1PuJQibyEJr3Fv0IjTVD3ffxpIDQwIaa4MPDoOYocHvtq36SVscQoE+KkD91+D1bQB0eossVnrMDmR6yf4+JLMHrEkJf7D9avfTFcCtCj55JrLshhaC1jl21CQqlLSIsvlFlh/m+QX6rIflqNUF5XHxFQo68v6wV6jSMtpaOYQ3m1LPx+Ai26GoNg0kS+ODnDKd2+sXEqRBNOlmRNEMyojHufUsUZvH4jaHGEM1gMSTlxI0+pRhoINMFO5rBJ9OIq50ovoj6CX6Ydz0EcaQejvTxu4Ph6/fC+jYEXStafMO4B/7ii6sUea/Lf6uHV1FEKEApYb78R1WpEOL/Z1VlyTcKaLAF8ziWmkhgoBpaPrNBfKe+pGxieqAc4J/bkKVw4r1LUIGlEzEox7fp7NqDRZcsb/tPf/xRsYBMPAwyjle5+gRBQSY759mDwX86sjQSvmw87cGPqaVSAGw+MNmv4NVzmZpVjDT22pwAurnHtSMAuyVC9yUzpAQh4ZaB1q+cEnh5yBMZ1cUTzQrw2Vtqhc3FJqdFELKq06A+E/g9Y63X1RW9d9Lvb0Zs7nd9EDWXeoyMjgo/V0V7T2ti9bBYJMwOuQBBaRgNOl9xk33LWk0TB891uae2cZns0ANraW3kRyUW3kfgRN0iOkHZEYNcd0y7E3uFPmHgTwFxGv5Pfi0iFu/Q8pLzlmDtJmatD68HGiLaQkCMsOOXynzm8pP96f1zui/MWiDzD30rDybynBGKvTRvEolQC8XhgAjZOAjGn27M+j+6sXQsp0kyDuMFIT362iQQzVW6tf/htuW3Rdz8u1qv37CxB/ffxPpA967yUlOf8Ev9V66TcKMA7Tdq8xFcu40FE/6xx6H55R26SKrUnZef0P97v97nS39CTTa276/iWhOr8Xjl9DBkJao+kDLIx5eIrKSqMNzQtyss7XN0MW1otguv6P0Ay6dBsMvlI23PB4DyI/4hXmZab+A07CPWDlBXj9N8PT6SfL0EyhRmB1ZwLw5exEuKo8N5xeGhzTxWwZ9cd/uH4NCTyitP5PhT0T/PorBjxJy/CuyHHZIGllS/G+/u2iJRwBUEMMhfdK9Y0AV3rqxhrsMjvclA15ZsLefCg7i2/jz8Xe/oX13Cek1fCdHbBmQVgXQEM7SbbCTtVgnuRZYA/RRfz0VukFAUJ/FCNGVxcc1WGcJi28Bd2n7COTQEGAIgbiiYUTv13sL6oL5p1T5+TkiRYdThT3cGOqhSuPsgz82DMR/PSbir9PWTRX+HOmWn1TKD/LTM512U65tYfCeZdyB71Yokn9Yr6rLoUP+DWcCXJhlQ5ZNabxBUHM0qPa6282u8bgyxz0RkYQl1YwZApAc4utuORPe9TN5ZnjEC1aivrEjwx0DRjDwGDZi1KD/v6zlW/dYyouzh3wcxv24NqaixCx2uIn+rAeUA75HbOqOHHnh7fpsQuL/yjROgnAfk5/d9gawz0IyQ38S3O/+VLfgp5E5K+gXihIGS9+StXgV7auPrEGaphIAOZCplw5NMv6Rz8qcR+Cgqi5iPo4bQlf3FWkX/eYFO8x7fMJohlKCdI+BZkmLgMA7SuSybgaDpvmJAv+Z2EB8wEPXJjDqHppL5TxzVIAGbCv53+vukOvDz/bnc+u14UvA8ZxBvqg5kKr0dPENgRbb7tp6XXghiQvmzHD/wNN8fiOGtU/mddKBDUr1c/DzsDvCemZ0Aq1jNPQWOfLl+Rjz3GtBYDRlOFy0i/k92Vk7ho5C2FJeV66e8YSG7yuOHGVLWEtHXDC8LRZShlj3MXG1r8QWUCCXQt+9WhfzVG+6Wg/tixChfkuQ+7OA8770HQOOP1pAoxBYrxPMyhkO5CerczxIO6B4w2PagbgmV8KkmK8Jr9qj6jaTE2ta8RxXbguR6FZSwMQ8NDCsDZf2R5S9wAHBxDMHxQFMFRAijoVyfh7P+XfjktxR/YyYUM3PDWoPd8fFn07akCw6E7nGWZSRq3emAuhRfpxpFpSDfYLETVqmSaI9d+2YCIXF8Jjc66R2xlMVI3thDn4UeNZKdUkYJuIyV2lbinv/+6fk+7I3PWVQWPD58vpAvqj63d8mWmpb8PGBJBrlSjDLTg8eHMPz3ib6yX1xwHj9sf14tBU6/p/qiY9AlFQynOxZNbyjXT8UAFj+1Uf6uWB0G+z0ZoRfswPMqpkGahx5m28Y/r999TguSjs6HQs7/1sw1L1nXW9qo6F+6xjBdiF3t0tkg8xWX3xgLO4ALbcT5y5ZQ/OLhmf7jmqlSLcUZ9O9/M01kC348k0BKMBloOJablO7HkIQKgEsgULejnRqMfqSQW+/Pbr6jn4g/r/mPq2Z5GaiVSvabf3iuYFka+rA4o6u0fLE52Bf6CCRsWCXBYEl4OCkzc/HBOWRnkcFC5PSIRGAsHDvEWA+JvgIxtD+XxfSFwwEJomSD7+BXJ5TDS+a/vdQihYlltK4apZztiP4EY6ssOlwyBbOvuf7LUq7NFP4YOHa7jOJYIK7EB8e5gYZioOUtPylMb95+IIIkVzOGQqXNZt4+x4WJ9wrOBY+tkT6ZabTS1i/uKkgpkl0Qv91RJMY2sgNZI9vtHd4PfqsusI5CBK6X3ED2ZMn1k4hn2b4W97vVTq88zmvQf8oj8819ESXIPK4vnmilrJ3QUaOP/WZ0ifZ/1MfGw3zcdJuU5Jv9q0r8dj2PmDz9f87ANUM5Dr+z4KVcifMOf10ZhSCjukYT8INrk/4BMYYH1CgibyRvUMN6zgRSVBxtmXZ43b9sFT+PazPmnp3nt0e1+IomH1D7e1ueIXUcHBeAjVZpivW8l7kxZaHb+KvIJ6pW6dVJI6nXcs39kOIx2oDoqv1cM785Fg2NhSz5cIFc3zS25vT8DUcyFR/LHjCY4HQV5Z3PxPmUeWBhKjso+hDC5qlc7vPk8bdbWxOLhGEI1H6FikI7RYaMYrg6C/ms4cq7XqrMy+Ba72WX/Qjd8jtQEeH8XvMIKhYd835IL6vH6NneJMupfUu9GUeP/FmCmM65LlrLVvoOH0R8/vrygJ+hMNDpD0I2wH/YeUzewpXTurl3u75u31C66aeoN2CYlwbEwZRYM14yajsyE47o7kkIBJIYAnslv0kK4gaT3fMdt0/PwQB0RSBU0HQ/I2VlfT7diEaZsv+Zhkvvv7kGKDE70uNUHt9+BJ/HqVRNYLwuk58CH/9o7zvc6w/c/G8MKTjcv0RXXHn4PYAD9PBqs4+84bVpGPqvbNUXjUBG8rHWy3fF4Kc22bZSJEtaVwSqCiQS6O7gh+tsyApea/ax/qvd9LHnk6aHUYk3duEln33Ox6dmjAYlXGFuTU4pcX01HXrva5VbNNm9Yfgjg3LFgEJzVQXHRn1ZaXjHa4BexfWt6Y2t0OxSDlA8DVuQo2JGOjDg7wwqKdauNYjXxEwfKLZG5rBeOPXDMQH/PgpQFwd4fn7QFpxlI/CEfQWSgYs146buI3MLkOK6S9Mv+/Gn4uI8wJJ93DMVGzVMyEXX1MOXd4xwD1nTwK7k1TfgLAywaFh8mVeVxbPHbrWyfO+gjZQFnSL3AC5w6D73iixRcl1/SumWEcNOIvymrt0H+nYzZd1jkFVmeJmqQ+2hAmDqhRbcyAbqnbT29UiiL//kK3Qco8BtuAMugRdhLDi3URw/PRYADpfNPZ6jweZ5naybfZG/Wcp/oPZe4b8ZNixyHIIsK+xvTlxJtdjB6BZvSWj2t2Rz3p05839+kZrAN0dyU7JXeXqRt34tSNPIy7YqS9dJaHTxfOOrT+DFVvH9JTpAUHBa54ThgKNLF6RKOwYlHxdEWwOH9Ienw/+XuQ7ZO7n1B/mLblC1+b9qdI9FIVOHjVr6rxppsYmDecfR/3QDkhJwvTl+ZIWnIHYQ/On/PAl+RIxoKvGcMmYvzJLm+YWhDXsKc7iH9SLTZYW+8t8aKcAgWPJQ/isItIoqPzicOhFofj13WaYfDndR2rUGTpG8h98IC72n5V/6zDwCOyiuGjeAAJ0FAxBEQRnKAIzqeLBblBTP3fY2td4Ik39cvQIeI31FuAdOtX1g6vszDSnIosvuT1VDcDloRB9NDPd2v8FIDyX8fRDNxAGdrOD7pWmjruf0RnZoD4B4SEVlfN62nxWnfwP82PaFyey60LYkRelGU++aIEDUV9RJRcGp/Aa5suNgTPH5F9xkPvmROrFoW5t9ULSheO1HMs0H4i4ILsry1NPUS694/Tds5Q8pZrVhH4aovP6cnDc44gf4WOm8C/XWf4rvwcrWB2J7IM+bRSxkfGl0+LJOJv2er2la1k1CUPSQ08QEHy55yLCXlPKbH4w14DR+NIGbam14q5T1C0sYvawoCSYUsrQqWInNZQf16tbv56BlbxN+nduOkKVcU1SVLL/4a9OFD4HjSQpeEETTRP/82nOnk9QoJPDDBDHPtoP9cog+IQXeQhbvDnA0xSioTif0CHx3swHvqcLRbD0/63oZPvGvoEV26mXvy3Atv5zJpExEN32UoWIevgCQbqCCuPdVB0AVDOy80rRy1MT/43O/JfieACQpk0TX6tSsUZKzSeC7ZpAO7+H2Q0c/jdRYRQFmNj4mHfNzCfZziPs4DQA5rVdKTnrvuCnXyuXYhje1U3vGt7Kue2hWcWeqHQ2xX5PdA7AoYuNHeKysK8p2SYPVGCbLzwPBEu7SqsdP4u9fNyVROrV8EScZOoWDwnlxLhP12RfSF7Z3Dax/1D8Fv65r2pNFJeCbf/c/vbZ2fWVD/1WA6iB52YwB8785ZfwxXsZ+X8XECZ92N79niyyv6WLaN0GbswyGa6HTZ62yjqSMhm1QYjNoqTvKjapTnTN2L2f8CEWjqnopxTCgCKL/YoVgHt0rwnCQ6zLBqCrRfVEs81neH7tPwfgv7RAN9PHkn1qRP3RM8gROgkHn4neyMFnW0ONezd/c4d8BPffoyBhlou3ZtqEPkTbLXx9LAxa8lYe77bvyWsPFHgdrVzyUdM07V2UTFmVLatWIgQYM3auSJkf/DCqlbgc0Ad94fnMyyjdZy427X6nnDARrdm/T18J8Mh2OQF76a4ITJUJcucgw+Yo0lowk9jczYAaCYxHUlUAWs/DnDKK8cxEQC1K3DYWgKYuoeRu+D3/o7qJIbsSFWBhUhCt4BYZV3Ja+JZlcJjbThvx4DXzR42fBkMuvNvigODTszfUBE4ATtN3NqxhmLje0Mn7hsSWZMuPz74dDDp+ojPC99szszX5QjJx+UxZ2wOTeJfhnOu5+af+6oLDAXE8PmO+bp2JdBe6Ovt8uS+Ab874y5UPQ03TYQq0q1lnDuY3CnQ1Tf2ZFWLXrazQTIscbksgpuaq1wZfXP8L8qu5iWQJaLB21lT9aOS22j1cseEpuMC0zmlw8PDaot23US9LtXAk9ciXr/hQXWbaXhGNDXNuErNcSSA7CpDHf5RrdOFWz+bd5xTMabtHfYZUKCvIRlpXFMT2wLIjXOZmBEXmfJdtRMH9eVREaRPfdrEgqyPZogs96etvsLownWoAZQFgF5twjflW5rXxAElhaW9X03/mB1Ks4yF9sBj4yQLCXIDfbGuuJF9h2uZpqmqV55Fh8C+q64eNXvewXR9JZ9KufQ/zzSS9vX1fD4ogPD8YMYAzPf9tXR6lblG3XF7Q1sxtdfdJqH2sVztZrUovvS1EQR5ajwF8/SzD/1uphGEZq0y8Z9ASC7xVo/ccjSc2wQqEzzACGID+k+8Iuq8pzBOA+Jem58tIHttqDeJGmwMiM0Aq1XeGGFr27F91Z4HpbHt1RDMxE8JDnU7OiUnVKtnaO/UpjdiMZSW9Q07r038ziSlPuIJ83iXz9Wy7tNG2lI8W4mZNQ5GsNbYBJTQUIGxAZsY5S171FjEx/FMY/oAlsU2Wv3wS7ZUf671paGYzcJqiLhVQmv/qXGaLJKjnf9Myf8wTOCzi8K7hiLd2f4x+ovqAjxcOT8aIDiaC9bUeXvEa7FP2SGYmQ/SGstmmyueslhFsqac1QaKE1GVqLe8CndSfngN12TwngJ2hlDB8agU8FOIbFvt2iRBGTdoV9Xr/lp39z1J/9BYju441wvLGRQcypBSLBdpGHedgVmq8CLZmx5vsQ4tj/ueXBqqnnIvNI4rY7tpgZUCDsuO+67RVQdYrt/M3qQytCETbY92qL1M5japMCAFryisqeZrSIGGjZJcET+fjHFhRjC1Il896SRcbsEnZuoCqIglc/93H2IBmEgLLPt6EtnU5xus2zzMXaKEm0ad6BbsQR1g2/4V+M1LZatr4XCnSPSGRm8BLdo7dEEPSpk4Hxn/NOlyS1u6dE6HdNREWYNMTafu5IfBwY3/Ppd/jM8Alr04Oki2gLMuaFl1UBOgabqkhtZe6HofPocrKAxdczmJQI7e3XZFVbvmakUqrH7WHGZdO19GWADr7k5IsmsiRgRTlhjAdsF4rMFhFHDW5uRSGj3WotsQjpNspolSJEevIdJhH4gqUCii4D5FRATIzzv11mdli/dIZn3pJQH6YUHfeH0j9dJ4tpsJPju1dy7oaE1R0eDJMgxkGLkPObh+cDQwgbQUMYFmc6mxFtViIGmv3EXNG1fc2M3G98TY5x/cmCMUp9zPlus2NCxr9P9uE3Rs4+7IBsgQOlAJLM6howPwSaVZqoSIGJsVxkUnYEdk+iJcXVAggXWBPIPZD4gC0IFKOQ1I1NA7eMu603D9RGgqqcvOFYcHNzbfu0e0YL1AZ9bSEZf3tGqn/7eAW20Oz3nEcRep6blQy/npBkwU5m/K3suCUgXRBx3TxJkBLYoV629ZIoyh30gZ5ntcvNqYG8yiuImZFKpPXo/nqJ1TjuahiGkzLTUBVKJpEJTEf844lEMTGLvy1O7kNRdEGoYOZjjp+v0HrpVJKj2W4PUWxblZ4jMcLHl9WgfSJ2/awKkdNFtSoH/qj+isStoMovcAmsLsNJeHhYLdJ+TbH2Q9CB/eNDE3ActPkg5jwA+wfrlbPhY3elzYiCcZKtiDSPAYfTaVkV0y0yBvJi9AoHayK6CHsz1gA8fOS8bMsZYAkRxSdkHxpZ30qzodzuQxg+JeUvrQ/AzJXHDT/t31YkOz0ihfLr1ueo3tj8RuA8u9muklO2uaXWL3uATrLfFHRxloPwY93ScJPydWYxHeO02/ePXT54gfZEmGkzPQbZs9UlZr8ppl3Cj8KcPsjaCf/uuh5SdHsdG/JTHRZIYEY7QYe12bbvG8sPUa6hh9Irsdm+iP2FCuWfykNXoMtva6cVRWC6JWkV5pXyzhMSu4TRtsbGJzvpzr1zzNe4Nhah3RRKPHjYWZbSJfJtKKjBRwmCGckjOsBR87JiFsGHqVCvZg+fWMJ8anJ+tcwZZnY4ypLf5JAvOz6MbIiNBQGBYjJPT/zt7m0MagPFSfunYAr83SeC7qeQ7dRaSESm+zzs6Z5S3XNu0XheDU00dW45BffACwfuRR8Tg1V5U8LIMIIOXhFN42SGtdfd7ntilmWJYJgy8BDUfXcw8I7dw8yQrtDlJPUPa1nKr/fTOmD+TltYlFhDDmi5CLffTqFe47JsNdgOJVI2LMuu6+ZiyJpjTQcmm365kWXfL8FeGhxEg2DvOg3jPuyLsdt3ZzdnIJgpybjzmTOwe4H3Vpm59X4sGlPOw5nznQ8CmDJOjJW514tSzX1Ds53/htu2uZnwApkpLumdrWZN7rlhdm+Oh5Qe94EG+lFLdAeGR+3/jOF+gdF4IiPpTxgMfAz/W3XZ4/n929E12W4X04xTxL+GDVK7ooArez8W9bsEeRM2ZmXdx3np7SfwEzXEsZ/5xx5Zf9w+Mt+sTEmDJ1mLeRX8zMLhoFcCsJ+LIAimfb+AUo8iNezW8PkcYjpFfy0IDomLtyV1NxBm3oqzTdmoeY87HFa88Rwk4KxAm8JW23o1eeeUul7+0ACu1mpPOzq7N05mnpWcpqaXxZwQ272oqOe5CqQTWOv/rbPllL5p7qwhJlvw9K9MX+D2lqcOd2bdP5OcgFQWu0xj+mOSpfGvHjvlm7apQRDltggE0IzYYJrLNrNAOYks0xxpKx+uQTF4153NcIJxMud7AWu7O1D/yG4QBtuLAiMKL368v1Xk4BCoXWBzzDQvavMcKHcqiqNsq4hBkadfWbCBgq3by3BhBhCey680bxVWjsRFVq6WiwrWtQ8nnoATqjBPFYKRCXAvqSNAU0WP8emCSNDS0lNPUCrncc/hA2VRBoRMMSzLhms7ovXsPpYZ6C6rdyT+sUFf6369AdqqqcLk8uqkA75kpHfcUt/datPlfYmBLS23bjwME9s8hbmg2P5yL4qiuho7xyl2UuElv2CWUzF24lijfpkPsMdA7ONicz99Rhu7Nlece+EW5GzWPVGPo9wtMONALEJSWyjqu8Jh9pbEMxXu5IsCn02x6kQ/1juU9M7zPFa1tsLx9Zd1qIbd8cc+HXULuLrGwYRFwMZeIOpVnneg4dftxpqkmIDhbhSVDatY+x7kF4LQN8LH7+Ss/VyZn9XhJ4Wr/SZIgNVLoO2r4k/g1G/O+s2poPznIf9mlO25XIkBBva3Ha3IzgJTL1wcstT0IFAYTf7o0X5jeFUEbLAQQDy1NXWzX+NO039yiWIBDVQ+11Op7qq5EdD1Nbd6d0fmbl1QpQGdtvz62KVqzjVxIO76DRxj9slayK3qgaCk9VOiifdv68ddPAeNeMO+cvKsGb/Qtw/75VI8hFRYnTqucdqQWtGPoNyu+NZ8uG1YbAJ/I3aDPEg4S4b48ZkzGPPVoXha1A9WakATMUvhexOunYJhaptUPzG8TFLax9CuNMVwRQEpaLAGVkR04PXg2sjue3+4Pg+wEZNjtCUoQL/rjUmwAg+8TWFwrMAvAxicUBwXNnk74eDo0y5cbPx1C0kVfD6WKDoFMPmZYU3lbD2605MVQfqITmUOYo75jb+VUXsNdIzyAFLMhRr57yJudSwc9DYbHkT6P31RyK9ZtqXB1J8UxEq9IwjceI1cfwTcnnH2wQXTGF5z+y5/809cgAuy8bP2ndFuGCyMbrrQ8+kbM1Kn1gjq8uO9TW+j998LpA7epIJrZrzdwMyGQyt1Z+3u8WMrrMjiISpMLiGr7YsdlTRQ9wKM8yGRDsYTo5iVrodQwbV1YG6x7foWdaTw+qrqTW+c41u742PDHDTxztRIx8jAZDeed/w9BkhNPv4I/7FrndWfB1OA1Jgb6h/HJRSvDEa35AEfoftGe+1c97euY+sz4aivoHbNpd9QrX1oh4wCXvq+NJLCQdZMpuDRedPhEK+sLaLoUQPOrVu72/BI+OfMl/AOGNNB+YJAvQTkn/n4PwI17au/0acZU/tRsuOhCOLfRjqWth+s7Jpd+3tsyE/nziicKuf53S7LojDo4TweRVY0epWsxwvkr5NmPvQEyVXPRoPAyBa6FeAYZiEVaikZvoMTy/eSIAh0jr7jOOalFYSScQQ6o6jEaHCHsq8fsvis8GP2oDEzvoO4eezDFzM0nNosnLSXhsxiFaPMnx7BB/DMLPzTcxEjokcDnS0J/pUc5LN/aBzAQJ1l9/0AVgl66eHbR5OuCeUk3UPUqxGK/TxxoR6w2VZNfQr73Jj7lJf0J47sk269SO5aqRf28MQEgZxpXskBoHJPBDzxEn6Z5aE68NWJMm4zH7jvLIFD7F9kKs8n6ptnG4swVeEdgaDFBqeKgyI/fhz8pVBP0AB+uQpfRwyL0wGUNrH39tfHSnuJ8nOHNjHZgOf/G0U12cs8z8nyf0fRCQ5EBSsVHJ4XCXtwZ0JUmdYDv8TaI4kFmRvwW/IG8uCfLa87+l6bHJyM7CxmU3AflpTfuaF+j+zkX5i5nCC7Akg1MOlBGCq93AmzRnyQQULgaBChUlrH113PVXoGrtFbIYihn+cIgNHrBL4YyY1RTpVfD/6w/JWcd9lOTWL6jReUad1CVm3Uvy4mQmNmM1W+lKwG4icCuVzJkR7IT0XuutsbZCFgknzwp7OI6UYFSbhmpvqJNDuShie8PYVi4iws6bTD+sNPI3tjPNuzE1k6C3J2dtX2oupH/HRCMrDs6yrHfbHjbzPMdDHW+IDMVxtmksuYLpYa0ttKX5yXKQScE4UJGUTE5vLSVSXTlXC39ed5IpH1+OQRkXv/58zMus6T1N+htq1rRr+O101N7S3RdLDXqbnvVZryTbSxsUPR6VqR3rd63vr8IlVA8PdlOYi5XaxJ3CeOtxsB+20IpGksvhiDdhUcDA4OOk2rCbNbPEaVjWgeow/+vV/ZwSHL6ijbZkpSbv2dHFuAe749kvyRGQmFp7WvzNPGLRaq1KUgY0USgzI5PuZ3e20fyEqFlem4aEbVgfH3/KWOhK2o6kJcxvN2FRIe/el4zq8tsXgvFsok++bC8j5kwByEzS1xSKZppvGgCFKJkMlKnIhWEYCzJnBw0sWOW6AZn2QvMwq9Pltuowb5qY7I3CeUKkHMgaVM4jJu8v2LdZxzGZk9b+2D5BYUXVNCzpfXq2wQkEGMc3DYL6/p3xbQ/RZwmq870hc7RNWqwZBmPdI9pBP6+JDq+g1gX25KcGGEQ2pctHT/W9NGB8hjLCh6jumUtb0DPS7KzgwE94v3Uvl3piKvwMb74T/dkZNNmNN6h1fx/dLhBex8pHE0VU7NKv9nGh6orHyE9XQFnSNeL0O6kw7gmntvYhCQ6SVp+n9VW8x7UcIbZM6qTQaPSClm4jGSDMM2U7OmczoDiZV2US1IU+jtC3QYb2sZmaAj6f5FXNDH+sXRpaKv8KQTzJp2eyrUChjsIb7zZPJVSsxy3JWvr4VIPY6SkSQTzf5RbQHX9ZjoatBvVjJlTwMR04p1faR/wunFLLYItkDwQK2UMGAMiXj4+K1MIxR96x++UB7ex/7qNO7AN4LYMQVL0YzZo6rOh6YLDul9NkwUbYhlcAWgS2H9cAr96F6c8U6a4WjtQq1lvINMOwPBqbGoOqutl9RyLzKwJ8Fpv8oZK4HzGI/aY0p4Im6gYv4TRo/j5HcYFi3+lUcsEshNc/+yRnH9IGIvcGLhG19lA9Bi4735w5CBky9xFPPuiHorJZZ+nmMzMQT5HJ955QF7uxZWKJLV3vBI5ln2Iea7FYUhEYSyEc7R+HfD09Du4ZdNoOkrPQHmtPIcRYd3C1BwCrhpAi0kSS47r8dMhGtkVe07/BDO6NTS8NepSTwnB3whezMVS/RRpf40QvCfbRMsHfBo/Nxsi1vpn//SZkCliM7BoRdCuasghJx1ZPvYu8NZUJJBg7+LAg15/bGm9wTXsPmavdeXAHHxa/Hj+mOkqhC50UcOCVakmSeKAcv587tob/nmE/7g0z/bDIfB5A1WLEkgNqAXNK+qmrXjej/1OCaOQnQeKlZNOwF1+3cMiVE39UVWFAYcqFYwe2X4oyO2r6WwstXvn2tKzSvDpOJ9aOam2TyxLY/PpStRimo9yBLZLm2HKc7hvl5WxUjVPTkBwDQbT3N2g7bkJQCnJ+yeb0qh8rB5DB9tz9saDaAw71eZwyw7P3H0s1JZP0nwActz7+4Q2rbIlXyR1DcKyyxDfyzCpLT3j5yOZhmhjKlAyDfUxPdKrG17X5fM6AgRmqP8AUhayA9iaSIB+YRlkJ2bHlffBWDgOQDAhGcMEIzd+nnnuwdIOw7FgVUf6MOiadDs/fw/USZ2kDuXLod8vwku7Bd5ycbKkM68RNqx9VRZ2ftd5H6tBJDn3PMnNQ7/JsGM/+g/3zaCQkic1JJ5DilNzQKN3VVZHffj08t67YNwORBi387zrr4PTxrb8y/7+CbF+uowsN5k243I7Q2620JN7xGSxmj3se6IMKj/zJB6mHu4wQam+YP0HKw6iQWVBGcOHNdcTLlO+iQLkUUctBchMic3kGtTj6drvJCsIMWLEfRTaeDQbkFShKNTaoco9SrKeEP6UJASh0Fa5IBsNFozRoHxe0FmiKbPQjCME8m/jnoyQ5huI7pP5YZOpkbvT0jLojHM7+rWhi2ybkiBGHJ8wS/tls3P53HsiRlRvWIyRwEWZl72h++E1Kr9t2yqz8uQjOCEfrsH5PTvZBEm51JOJix3QVZWzMSwf/h1mJU6BeXi+URr0v/ph0sP5Fiv6C4PQ8qyZZxoQ8B+G5IHE4RppdpVb0Acrb/I1Qxl+V4FTkP5Y6sen0L+trjgLGLVkeBi2Dgu9jB/N73XwOnWpoclfb/2Ky6+Qt/Dl8sk+Aw0f6ypBQS1mUVuKwTW73vt6r5WmVpt6dgZi2bP+wyhirbS7lqfWirDW2+OFv6ThdZV0gApOb0bhuNOVHE6aeGP/bO99Hyieh/vxXS85sodeNotz3XhzsFVOYDnB/nPUyk4NNhReqC/fDa4piiyDOM3NJo5fqKPFMfP2xve0sUVSPnhRWMj5V/9rv8Vt1//nTsR3B2o9b9Kwnqvh/jKC2IgsmBtHXfMLGtfv41PN4WoXFXnlNBDE8IOebh41gbP3yi2eJNJJrSecc7RSC7h4ZIcMiOq+Cy79IiP8SR28Ol4ufISA8+USsa3fn1spqC7kQOzqSTdmHh/M8VFMj4inD3X2izwQ896Wc6fF8SUjca/ZwJjrhW5H88PoveoUAOjlq4GkirTWzcPq51N8Dioa+1dx8slmAJTbiXzmp5PoeuOkfe+VVGVubeiBxk79pRbaYSAm/3OWhb3EVw8NzgrQ3T2QVEiqTmnaVDem1lbAllLu6MjHjda8WPZ8/SX6v2pzbJCMHYHVyg4jsXr2zS3ecwUmqbrQZYegnbGUPZYcaQFs69HeL3rCsZbn2MJ/nxMtR4dmBxgLJFApOB023QUdrfWFbA5CL/95V7TnJRJOxMZwy1GSuDchMcX+6y5zN8o5LN1hEZZlrkCGZIFUGu1kafvPpTHyfNHnlIf6H3fi3RZgVGVi7YTidfpshnyJrs8WGpCrf58iFXY7JV1Nu2rGT6WTmBPlIOoB0Lii/x4NZpo2y9+RNIUonlO3QG25+9vYlDetfxhRPTHoPUJombZ+N6qD8kJ/sTk/sgu1mn9PaZoYbU+E79PLdp6VGy+ZyuponSWHWkoSzDvQuxIXGH5cuhkyiix7hacHLJaVu/44Ve/bkHf10h9cQ27vU/2iZ0/3SwpaoBeK+JtvIvzdjZurDmPtfc+cCOgO74XlIV8b451LXlLQBmtSw5WhHCS/qaLNelze7Uq7h4dwDQTZekLkDR4rDT0ynHN1shjjLFu35SxaUnMJ8CFgMJCkbRAyBoAbQC11oHdC9a3m56HZlfdmySdvWD1zHjrGQl/MRxhlF4Jf3fR+6Tzq2293C8SUwzDMYzrO2zwqjNV/G4nqQvvt1PnvKfVvTBiJD/zowUlvA3iUOAW1SrytPfTPIxNbR+O5NnBIkVf9BukbmfwEiITrl9jCBFTzoAq94sDIxtgIrn4XBj1LasmGlKlijt1hNYfRjHpgfZ8Uj79ejbQwZHtohw33R/Zqjp13WHkxmXSh7xBP+1AlO/YznGD9MSJw847WSTajRJjbhnqX+oTY/Jm6MmPABQgu34YzLdVpeVjWNZmkSrIvpxynTPYiBMUwF4B+OLXIm79AssvTvhyc/WTWQCvJza5DmDn9aptE/UnjbAtRB0WxNQpdau+Gkqeb0QUjZhsOUAF6iCA2919ILr8Dg9mVhOcposfcGQ0nQuTKLSqKgevULVv8uAfUC2X4ddFP5MHir6TGtUCS6d3W84B4f/mEfU1wQGvmDwUaw4mpLSre171qmdJ2B6CThp4CNHDoYm8scZS+HGuLFTsUaYTv8mw5zd/+zWeFR9UUQZpPHnxKyrNfeaLdpBGQz+udxBbmfEC6QrqJU5385sCBjrBFBd/RzlYj+besxXUE83hACKG+UbFXFHc04tbuTmXTlffFKMakwEMoP+QOcB0FiNRoT/EhvV6CFIWyCkf1K4/3u+Fy4smZKkCPGsDlYJVVS9PMuooime9jo4gHIpcHXGHpx7gpHz9IgFvb/nbi5sTakd6S6/SE9NcNG0oGk2ANSn14k4zHHIPl6GrAK1Tow8gJjSHx0lK0f6yQ8TIg94pJ3eYhXt0Pg5wJxSIn4FpXteFafhZy4EjQNrv+QNWOw+9uREUnpZas8ty1xmyjqBaNSFIXdsF7DeARcVGO+D3a93TDNw9DTK3IqefndSuqkRHklSKQbOW6OO0AddY5epDf2ewilHyi6IBop8vX83Wm2gPwpVN1Gyzdf/WyKcMKUjz+iug9yeoErGTEW8tU416l6OX5Drnc840DG94SbR2J/gtSVw7/20Az+9ubycwMYolFRR9EReQlLD+RM17SbpKcVLrr0Q2JJg4HJjGkHGkTNXnG84jWM3T5VFhOPRvemqhBf0ZWlk+PREMPBltLxOZaXXRibkg5GdjNWpoAbraHgK+rvT6J4ZzDxt/Pqv8KNCeqiOlL35dMZB3UNz3wP0ATb7y8iUeDs3PdHlQ+IYs9oQh+t3yMkaJeFSjd/11CjdCJYhjL5hE8SglMs2IIYFidSXGGCGAxzxQybiBJxyAa0Jpo3SL6bd9oNfJEtGO/cp8deEt7rbgH4pOIQp37/uxr4mbyS4REyIKsHVKguRcwEyoVPsKks61zXm73eNeAn2HnGR+jeAZpqDzyDuImgn6yXGPAsaJCM1uCpBophvFQCo88jcKZJnns2NOadt3J67IfTfUWbYsy9BvXEpANOIpHn83jHoDk9i2nQDP+0jQl40jfQiXQAohLvprQ7OVm7f+fH9lRo1VrvmECuLxp5TmD6CuKk1CoW0+UqgsXjDFvMxLYyZGBdqkDiztEnkMT3jWT0E/OggKTgPxyIdtlVDPwRBqxKBBnowGKWy/fgu6AVpdC1e+u5BAunwQezEgGPiwlmnC/zhL8Pedu7enHX1VhJHw4be6hj6xaPmvUtZDpcF94Z3r6jFaXPH89fz6py7wk1tnJAu2V7UhMIxlC+QXDNFPhui8+aKO7fa87SG9fJmpUjXn0LNDv0S8Q9Bm6foDRnM2v14NzO6fyH2iwX2FNKsaJykMjC+DTBgS7sbdAG+veDMrFGdWewjTnD1z2T//88QEkWs32TPkC6hhub/+v37wiAMqW7vDTilnARL/wAFeGK1vKdOFxC/vtXUfvGK0uFktooEoLCu68q8jU8SP4ET64UF9mXI34BFSecwq34b//D//7KAMszFG7AXMKqd+az9+++2oByPxMnofh10MIgxDfszMUlnZJK3/ap24M+SlJH5r09MH1QymX3fJ8xlbpNUU9JBzUJZiDFs27ZdFfEvWMAFf/W3UfQGfkHP6Txhuy791wx70YDSfSlx7PWRfpJnn5suokxVeHTEQe+fVaG79eXbKRi3fVwhmDT6/b2kaQKcSmBSSiqdZ5fc/MxfQZpHld8c5eL8w8lVbxStMtSqmLUEW55ZcGE5dXILOCxwHTzZgHnxg08xYNTO76idWyztIn/2pJgrKZHYe/hvCLsvHtdQIe0F5kOLjiNQCxtGSQt8eHMvtYdRgLNZ12uL19WD8JRmFwTbsS2or417AvkSQSA+DFtUECuLa8/3pgjD7Ovf9VQcbNhujOIO30yKZKB9O3USZ6hQfx3Io6dIp9EHx4Zjyjkz/nfeRBg7GtbegeZOHorMPS8atvyg1z+EC5j57DvaUHGTPM4hlIlqxp4qHHu9KPobHFs30p1L90x/xoKiYNrigMliLuiSiQgkri4PVxrfPYtYxI+wJeLeNfrX0NxofE0sLNkLZ/RpBKeWNQAXN4VPnV1udYy1aPU4GqNfoGoZaOe7lSyCxl6ZtLUkxDS63oWyfgCRho0i/37c9BScvVb4zVq1EefeisPTjvPbNXFGEgKJZZnfENoxG4hs1Wpj9YvcnKKnjOt1RSjpJxqGJs7jglQqVeK21uf2ZtkKxzv3R2zHcXybSedd4ZxAH5+MZdQWonyMIx+lznNIytoNFs+InszThMSR4JB97momzIf1pwamU/NYQCLgWGtc6hb0MWRbRx4eTZppqtxsFBvP+zYqOorC0EbCcUHLuBxPh/brJoYeUZIHW4pIqxFs8EFR+mOV0S9Dz5M8jMlmDqQpwAfO2vRA2ppEnntFAPwPqSdAkwQ5Jym05QDdIFz0/qEbwK5Q+F3CGFi7dxaap87x42KeCIhVR3EJX55Xnj2rx3iokPSgwwsjfPEgjyQQJCvO79C6AFHd/51jnygO2eCzPdh1+nKJCRazQ/lMjKexBN7x6ju8EYYBXues8l9IntOfF/E/fkcIo8JBrzktfgMdwdspW+mzb5tVPVhdEeyMA/4TQ0IjYDXnN7RS7XpKLChjRwj5O85trB6Rg+qV+KcNxTih4XprDSiiVz83yW14Tu2N1lyqxiX4uxjOLhvGAgj2kBAmNAsOU5yHWb66iWP+6zAafMOv98nE8VPXszEW+xmM9185QhuPmz/fO/It81y/0Vsp83/fmHWUacIn/9Ky9bHp+oJyi8nfGfpKBeRxMV8f6uzxvg7Nf2PYiyXSOPiSK/hTiAlXRdtnlfZLgUCALk/JQTZ6vAYoJEqpWjigL5sBrSmVDS8/NqNHvWarVN5Obxl7D/Jfm6NKtY+F+1USdyLnTirUTDr9uA0e7QLud/+mxgj3tkWwg6z0qvflpxaz4sTpQ3ziLXMy+42wgaMmPl1tWsrXEkjSlW/T8MJMctkj1OeuQAc4ArWHYfx2ar2LssVH5ZfAAQlD4NMEEnYpAO+kN7zPfyT1SbGZkZFuRN5uQ4465TSetym2eGh++fcDdzDW1efflF6+qAac6op14thg/ZRDo4/b5qITabFOmBDAwGa4V8I5+tL1sZudkkrVzU9ZG2c74pn7/4+jlGjGL/lsZKtqeiyhTGzNmzpe2NQSkadNupQBOXwEdkDngw8UvYRehYNixDYD6KsS3UoS3cMSSgmJnywkRYcujKLwUZK4ErK1dnf+z5spFUFRVv/SLuc+eFevsfLE3wFijhCyDbYR4ZakfAW1Fk9GbQH7+VKZBeOEshtLxiqg75+xLLWGvmqvH8IuzOweKcmBwMcisONk32pNEBWfsRPe/MxE+nW8KIx0oaPUNDXmBR86Eteq+URSVua8nb9pRiHGBM0yW7BDCcPHngQrPZ8DTWYgaSeiCDMjF1Tcx7kbvIaIf8FYi0W9yCKDlMUWAH3D4SYkQ+7XGVf17vkABLDoMcutaIpkFA2T27PXwcc/rWbumidT1ykEQ8lRGafa2Kfq0qLdYcZ/oEwb2Rr3pROUQfqRj+LmRdBycgESypuJrXX7BMA+ucntNznShH3pqwiysoDvb98t0+9YkmhWQrbV5otq5AZxyVSXHspTSJ3my9jCMhFktVbkWGyxxn3sBKp5L9Zvjyha/zvIpnFpwF1m67d5Exmtfvi7pXYes8A6i/hrVFmWMmEtq76t2D/Qc6pSfYL0I80tPexJvhlz2hgfS8EvLgMlJXl3xzhRArENENxvpyBjTsEbPKWlM7zSr9tnACx28UsDFzxg5v1eaI27ZHvTRngbmEfIi5WuV534geVgmLeutgYzhuUey693igyhLx2zfBMjRQnCJ8Qi1+Dk7Xb0qV8PlqKN9aDpM8HtaFCsXzVb9gWNl5T8niXw8bTbj489+qs/lH6C6zIyyQxU2brBe1xQSwsCqb1XXCSYGph7Y032VXniKoUczSAoxLCY5Zajalnxzs3kUwiftL4LGWc/ysvRlVkBMaIb2EUi7+RybLMluGSMdkGoRaXKo+Imtd95+qyCFhcLwtaq58u6yReFUQfj/MfVdW44jMbJfc99J0T/Seyv6N3qKpOj911+mqmd3+/TMVE+XVFRmAohAAgE3KUV1b56DgKj2408eBy29z/c3BthD/UQ+V9W2vutUxe+1Ek6YVB/seXTcrZl29YRU5Rvb1zrpkppw1dyOcWBsBhKy6YecVA49BlpWXZjQLKtujQ6iUhjkrVD4ygrQcMK0GhOxINVx8GKYFx4xd9RdpMtHDZA43hAK4PTKUL8kPk8bG9KeAr0rDWecvs/jQrIzT9XBaQvtDnEVKmd2Tdz+R+1QaGZqteCoN3mQdP4I+q+LrPetOjjYg1ZwacEUhIO+QVGwwN4l9UGqK39kto+3x88DXBf1S5qZUS+/leOYGEk0Cy8j9IeYsSrBuxdtvI6EtQkyYyTvA9g+2YmoMMdoZAS2Ut7Mm1k8h7aZm2FpWWus6S6Gi40EkWGrVHGYTZmG+6KzbhwdROxfwZrdDfQ/89puMjkdyjFphpYP0/Xeb8Z4PjN9vt7WAsmg2eRlSfRJjpP+upYvqUdkWrUcbg9UwAMmMdJF682J+jXw51vAjR2IK3548fV+gUswNij6dLiyX+07spdqjvQcH5wH46DEORm+A3kuvsdZNIprZgCHuSorOWAEmLBMonxqVxlW6CSYFfSv32bF/K6DyRy/mmtGlgCTiP/0u9buEIRATkDcBCcB5lFVCc6rcZEh+Q0/me+gEDlbWcT3FvwYdNavINhB/JuidTuAJ0+41bwqEGaj7HHpaR37pK9KKIqvFZUGO/Ojbq5yOaqmVRECrnqC2RUP3fP9BHf8xw6ZojER+W+6+xNRL2EFKSKsa3mcU0OGOaVBJu2bmX8iIvS1bCSY1SnEVxiGDp7hZT8Z4Kb+Leneq+10tLjRT+WiTUgIq59E63lHTvbQ5CrG7IjBwYUeIbEaLupEwswoAS+8EBPaMS3ZMlFQ3nXnCyZZ/ig5U0iM3v4BHhYFM10LYhpxCYOlziiAriROP/ABCYNNc123gjLBPaZKrbef9zRmWEfDLAkf97RYFS48eFWgR0vVPXJl/Ix556Xlg90+ncUEE1oZc91oIGU9B+uPv1tFGFRmZVNrgqLVQ5WE826s4lTax2oTjjzCbMP/h7wSWv8CtfMCNjesFLYnuYJVKz4CanYKhXLtS2iwQ1qqLjKp6vGI6alWXNaEs/fCw3nAyzr3qbUHebKCbGEsoQA0MGIa1CbkdMBmx2qqk1nzeI17nJSQ5p0kPVGCv4+4Aty/JHnEcT4nSjGdZwsIrc8XJDY/zMAMozMp/OADr377YNpyDeiIuKDuLFNwYXh/3DWsLpd+1ySdfvdwriFvLIrj/jotMDEmeJXzXBgBO0vBZsrDHT3Rdn/O3l4pSisblUTn0repzIj+dcJhnMR2U2r8vPRwgrAtPfAcDuy8aRGuGi8+UNuea+q4fRU0H4mNydETru+oCbBa46qYoT0GUrPx0GW8Id4RnyjJDgOn+ZhPjIHTaF7Je9n/POu46VhH+xIO0jUKCGf/qohOqqGw1ahwC7EI6A6Isr69XoCoGo6pG+UoMNGrMImAVSOqaiOZqDw/KqUijMwKH6C4oYN+avQehkR0Cf5XEROGCBVNJdrjD6sLQL+CSJFZlA4pyfDrB0p8C9VC1tus9+NV3uCBx9gHiCL4BBX9JiKAItUeV5ycnAueW5s4gHCTKsu976Qn/qZGe/U2RhDMwMQ9BMx1d6y9/rvJwlVVO2aIOTeL/gxaq0ZRgu4W14PEjaxgmwB7ntLRhQ3qRJmje8m/fh6DREz1+32hKogN59n35zCRM2ZvxgWNrxbs1vjZEDGeVlFDKTDJmoNaYDFkrDbzyxxPWsGC7Uzakx69Zevj6YK4rJrFh7rIFRzpsJ1X5D5U9eeNujPbHoJo0gHixFVQsk3FTZ9KpZfGDhCebvfZnxHCv64Hc//miuDJwwy+73pXonjEwYF43qsFpf9pU9K/9k+egrh5pFH2YwLXeFgpk2oiz/B4nGN+L2AoMOWXg49jQsVxQU4nxzUqQktw8ILNvN1JPsouIoBsU544pfg2y0vp34gtnkdgul+51G2KE7dPvYmw2h2qItjheN53dS+zunx29aZX53lKDomKHKCMv4r0stJUOFPrB0Q+/IYQA4FDLjulOgwadqzzPof6Coku7nreZwPmlcID0BVCthdfbIApfR1WCopUcUGfqVB8+/XdWQTnSa6uLxAWN9+jphSb9XUHfbkGsamBTlCKw1Ki7pnsbPec2OZCH2fOvodMNS+V/NZHnvzdxLJ3Yi6whbnUnaHbSpu0l5Xl60t2CTaVMDg69lt/wiyfCYldf9Ta/HlV1vJnw0OgcAVV2wOKD24PblRhF/nVVHLhpje12ifuNqD744JLYIfpG85CJKxBdW7+ismcKjB2Qt0C9OPu2XGdaylNM1t+UwrFYj8stvJ8Q0ZQcvYX+P/mkAgUGPjnpy/Un79m0Cxz8KrRbF9Sws13Qv02N5xScqZjiyZnLaJJc+vGFXh8TXtBl/p2Lvxfl/xDDA7jYgfE/ixIHdtByJHcmzTABhr3N0AYMWroYite2Ye6BQg2ma739HSK8JM9v2NbfVmclh9kFOFBm/bNKmT/zf/wq3Eaq4SCq0kHN296DETKhXhoq0o6wiPzl7jS2/EMDNidKOPoZWRmesjp98WvEh1Ve3nlGpL36/xdbOfpiFr3gXjmIyvowzFf1kQ8HGhIHxD9An6kUMvXAusX7ZjeeYULFhBMeHFb/D+IE2tAhFqux04OVQyiVaMWsb46N8BwlEJ890p8L0fCNHxYMhG3ZyAa4OnzxzyfT2hWbSnRbjh1Y+i6YLro/5kxD955wg9fmMzHiJ/HsQ+63keklysh0t1KVbccwFivn1dptwt6UF/LmyOYylPom0QeJJFnD3FPQcdqS5B4TJEWgaFkoRzzfWa0xdDSTLzWhPSV2DSyGbdlMjAMCo38tu21jP9kkZJw77yAQZ0Kc7+oQAAiD0xQivBnYk6zbK//MrENuLOqpWVkfCm66XPeMlSq2kx41zzyRhKRXmRJ8QqvG8G2vYAMAdNRx7i76vC85u1lNI1iPtQXS8R5AySmzeKB0ktkYsmlkSPMN4oLPd3PEj/gr95xKlnl7PXpbBHt4EjWFGUu6Yse9eu+xl9ujVr+TVoD99m0V4y/MosDTuP1RcjzTBzXKi+Rw84uUv3rCaWIgOKKvjv6cvucL1uDZEIECJH5jVSxXqJFfl0orGvGMFmp+9QZxgweeGdBhzT6IT/U6ehdlUutp2cbH3cxjg8PNEtMFVUlFrpvVDUVCiFBnOkhF/RcCigCZgNcjbWcnn/D8LYiXhUM4U/7SogefwhyYf/0arYPug2ZRVHncRxILWmJHPH9t+ALGEtPgzI/DQDiSXYJfnB+J515XJbZxIOLXR+dfl5aJFEEaQkm9M0Q3qnU+BtKoDShH+PwsmQZXBOm3Kgaf1M7pJL7rHWRlDVwPOEhv18a0hjUd7bu7QV3AWHNxHCcXxr7OHT4EBeg4MqwY412X9ADqRyGSJTzQdJoPSdfA7646XVhWfGNYeq2tvufDEtvkCqRbNHb+SKohAeQXv7aQYQU4pG8A9HqgbV57+bXYbWsD8c5/fq88QWv50kuuV4xO0fq7S+MWaYitvqGRlCcmAZKbMFGD8rL2WFiUK1aMF5YGyml9AFeD+uBQ7QSZMfgcdeRKCxIEONotsD0kNRE67RDwCrEl+MpgEiw2rtFtv4BM/EMbx4/N0vajQrmCvuNRqDENjFl4FaDQ8nbB0fa2fnpv3EiZkPQc1F+fbEthgdQgNOPv4lI2+F+hfoWSGX1FYiWHLH8/jeLuj21gW4+kz6B1N1GOKP+/WZXmkUPRlr4gBPwyVjrrQVjQkYhHzENgmRIJJLQyN3qdlwg8SlAIpDCSN7JNdELeLrS8ihulm5dLzR0s/OHalL3EqEwcp8fsPDL7pzVGn0/Hv02f1IlhYhbXKX6Kr//0EK+myaEpZ8WTleIm3K+Z+v49KxK1TGN7VU5Gr1Lku9LPIh0t2pwkueD30oCx28jkAoPIHo6yzKeLRekLB4Ir7+WWTKk1UhHTYoe77a1mq0+8Gu0/Bxlayr1jRTfU/Pv2HR//4FCURnqu+cQK3WGQ0FjKYgC6DI6SBKY5jvPQ0Itc+X/Vw8eLUlnrNYa8aY6+EbEg3SuVvH38lnUz2S/9xeW0mNAfmX9knocOBX3I0us92wr2qDQm4FTYSCo/nwjMWO5Dy6oQsnw1Jqr3ZPnD82KFc9pupIWSdv5HgpcOx+KErSrPsM69pRmUWguK1sVqTgJs8RfV/Ii3QK4RW0C02Jl+f3wk/21lZJA2G71YAyVHwl6ugHlFlzyRmpBN+Pwdwfs71P0MTEMZX6DI7imwfFp2+t94UIYg2E/rI2Xqds2Tzn9A5VUH02oZGF9Dh20W0TFJs682dlYFKwFIbxlqWNcMmaKbpmbSXVf8tyq1NcH1atWoZW/sQ6AWb+9IGS83Gck1oSTmYPctX8ZVZXBpY+JaOzXL7BP1SfrF+BPIbZRboEPVx14m2vJp1xiIJjDrWbUS/UYgNyKkPAkUJMX2F9GWx8PRJKk2GOe0HM5zokqIFZrFf679U42CBgpSpgVAOPgrO+mPmddxjn6rj9h/uJyS+vMPY0i0jjx995VIfR/ZufAfBjY87KXjdk1EgFJ1WSinfXOiRE6U7irNYky7GC54LB/MGkgbgaL7WK+MknmEuCkPn713fnooL8I17SMBjiWNSqVvXhw6mbqOiL5jqRUD5sqLPEb5cubuj0MVfP9nClAGXHo88CYFl9xF9tSgTnA2aaZwBvxqR49YOFrla/ITtXf8Y2b7GWORJNQPrDXvixF6d7iWFs4EqXi5kwyg7XYJajJOYwJw9SwOc3paaCwPOHxgCO8wyC/pAovZIl/sZaqX9ZeZhlp/kRXvLjAXpZt8ae2amOwMuR7qppPZDYujICfOJw+4mUCUu3Kufrk/bhiZoTCBuegHaBsJ9/wCFylscPWqMM9etQ8p6n/jYTeNYCXhZwACLsK2eriW8rBh8X/zXpt9Yf4TOYNOrjxpc7qf1O0RD5jqw8hbW8wxe7yCkPQg3RCm7e1A9/E0HokY2w3e0zeP3vCDSKpgmQ+cgjlqkpruH/RcD/PKblfpSDM89X73ZG+vAdxQSzA3lGsBKpp0stHRayOemlVfbmjvA7dT5/rZEvWCLUFNLY8PrRgedm8uGoQKg8LLixZ1WZfmcPK3sNEtyDz+cSxvS73T8KgkkSPp00rD0nyrqu3GMYpoWHjZNXMeik7iK+SKwGVmwuVaeIuoyxzGRFIGF95QEUgbwWwyCw0CSylHzXJ+vTHPSkFRIfb/msC+DWkl5xU34gIJyNkZwHR9nlCy1INSbDy+Wpzega7rf20gCji+VV0DLrXNw/ZA/h54W9/YBI3QLHL3wRV8dUPt4MP8uWaiGQLwenvddOgx3x+xmIxf5XAV/862KNi2ydq4Nvu0bUp06+bNiLrAWKqbYCbhZ5bbs6yOD2PSP827wdQDXfNZcCkxcfO0evXEc8P+B7OEG6tN2FlDUb3q0SLzWKyKBaF+rBbmgrS8Xzx4g/ebx72AgzBr5pGOm/hQXHfTi+6IiPDLMUGS4TuFKSye5zaPh8Vjotwb+rPftySnhVtbL4PQahcc26nLpiLzidQMjHQuHn4wXihFe2ftijTDiKYomw3bEuEDQtMpc5lOYU+b1614cULw/Mu1a/eWcbQpPFJcpGJjbYObOYj1r8xy9gDGOqGs0xRaUKROVYTQkd6pj6Z4hAAeX0/Ko6j8uZIqNqIWeyUz8/lAxrudV6NY9yfSWRPQcc/PIKcTP+ygKCwACDvVC4P5FHR1wc8XBMJ4RoYfjS8KFR8okhbXegHAX1MnyZ22c1hz8tP60ayJjlvPxgOXqUviMmO4+OeKPL9cJ1miB4Y0/fjRNz21TJEEym/+ycwKVTJ52h8IanxvVo1P59VhLgikC3sXXfebnuRfZHXgY6jZv5vnwoOLrRZnlJgqOzRuw+yPYI1cyKJx6/200UYHw2D/DyWd7BpRLPBZUcIXI2jQ9vX8znCBX+l7vf1SWkJEvehbRp+AhwxXBzk8yvBJHjuox73MBuX4RKsz75CMc259bqiTNOI4xiFr0C/QFgifOCxJxR7gDLx5hCN2izTTiOA8I9P2yscGSJ6iJDQ18Mz5zFB0vByzhmtx82pwffxIEEYEt9vULI798H1NO2c7bsJOuH+KqNqJZvtLgS5PAAk7eSFX241GT6NJO46meNMbT8H9EbZhbwJrFeZ6XSedW2MgPcdIWY1zfIXV1m+rt1PWYltH0vOzkHOfTZ50FBevQZxT1e7ogTJLjt7RdAbPjPJWnApteohYW70a5XIf1pwpIEpQKlT4FEExHIZ1s48QMgmg/mXJ+dv58juNAH0A5d4vVdEC5Yfx6nF7jm0YL3SQpP1CR+YRTbnQZGl+asU9Z1hH4SjlH63QtyAf0oZH8BmL9MKstN347EMX9nf1ErDe3+F9RB46U0H3OHm0+nu8hH3kvUutTG5ftOvCP7L0NWCzRlWv1qD+C5bef9QjmHuBCFM4ofOSdajVZ/TdKvqztCAAf8/h4Pg6cQ82lY6cf6h75epSntRpZeib7x9zvBpTdCUiCr/kAqALGcBTp2LopE6dNqi42Ts3aP/Orj22t4R4YWZ4ZWmZQ76dTNn9oTLOe/RdCWqyLJlGFFnqtzx0iqWHa7XefUywXgJ6rxl7fZCAmbTYJZRktQnl8FrFSpscXkMYKHbPP5GF7nsolCMt1P5oQzyR60TML9hwM/vdrJiTnaRH2QAZIWc3ENrFb8xCp9DsCeCId8ynijF3hWXkfHpQfpJIo8UMlUk8qvNuL/3CNtUR1nfsZ7gPV2m4mXVhxp8Pp8XlCk0Tcs0ozi84Lltbh2JgUFilnmiaTdmABmvD/zdT9g2DtRNsvcFCo/JTx3jDrubE/u6HbJ8rNFSWBd0POhdO2HOgEj3p0WPK+EIvgBzj+rGujZtYLGXH3Atb3HSw8+we7KsuPUjEVSC3Dyqjgizh4womy7juT1KV99DrUO4tWbFeLZcIxDImz+mNc5QShAYttEyUzliT+tPmKHRdAXxUT2w1V8IvUiXUvIZQL+vgCt/lxXSW5d+krDK8cCPcb/HbT+Luy48Ot54zkHAG8CX5pYu/qtOQ7R5v67PSmC21NJLXCHjG12G9fkZhmRojPvr4TneaS0dNaj1ldkbjpwvQ36tNCbLW9n20CXzbXKzmARhPEqhm2DmkwAVD8zA0cRPYW8oiZm+hQm+P+q0Qcc+ASIF0o7ftoWUfOwpycJsAXhtC7elsRO4aqLl+2VTSyk0QAGggvDKlRkTUwwgepU0wv9i/gHksRVPcgj/gTxfgMc0vhDuz1Q44WfQNrtGbNB/NdQ21rVGO+xrW/a2mFnQqTV9Jd62hes0NGtAS8olDxFyypCTwIqlv/s3Sl5PWvi1oCMaXNOoOXXUyISgvIWtQqO2qnGVRZmF1FKkLclz4iB4WRz8OAew1JbhhbgMCslMdv+QQOViqbKy5FiQJuGPJJWXUDuw+5ovFwWOjHwiluRLKVD8Yw6cFoQgh7PYUHMvHnFhifHNJvI6fs3bZ19iSQs20S9DzDRmQvY30U1faDNPErO83qtRgxXd30Lu49GKvaDP+dEiJrzpmki6xYZmmbHUF40J2cjnVUsEX3n0G/x9Z4IcqgL1QqS0adxCjNHHfFLySFvdSg48WU3jDDpQc4c3n6/ZyIbJTZO0pXPGgEQZ8NOcCRFab0yvX0OyY+qIp+xW2p7qbmGZWG47Gc6ca7BTfErDTnu2hEJ1uIi22cS3J/4kJ5yHG9ZgLQxoicmoxTyD5lV83x5Ose0P2Nly/75fTyRH0t+km/xX3aOXGayuXJlJwM+4LvatdfBV+v24EuQu4k0rmZ6r4IJBN5sBSy14SCWYWcBmTdgrqKyg7ZB2TpwOCyETGb/Iwjh3AH/MX6JF24YUMkMu59J71Q8rkXGmoWLo8UOmTIEKNbGxWKvg8I4k2EwJjfy1U6dybfCMjzq9jc3DNfvyaELPIaIXjrjLwc59t54m/gBQdwWz/NxEjkSMacvm7sX33YeZcx1S08TIaZrBq+RX2lsqye6go6Rh0xjLngCTVTK7psUvfY4daVSvQrO3THnvD/K2huVLlVMLXBub9/aMmqAiI6pGNCFppHXjHNxdleFmmwde+FH4q4uGwuRBugdic0SCSRSFPBgCFkxgKUu8brj1RDEqAHcy7/rXGiNEB0jf7C3lSCrSyjcCEmaWZTHPEcQJ90QbYFV4tmv0nQg+TFFrWrAMVR5RK5nvSn25zeMPFbboZRb1gRgdcz52NnPXdyZxV3uI8GoTiWzyBAgonfm2LEH4ZYC286AUPGe7SJrRMdRBGo7J48trYh08XcivQNkxLBZWI7vqS6SE7j6bRbZCQW4sADmthHe+SMfT3KdggW7TXT94AAMFGnL9vIh1OTJJD883vhgoT6+P9zttGWZ8h4nY8eWAXhE7B0kRxpdExc5glhwH9OZIfoqefV27JEGtuutgKIhkJHo/x1iIzKpJ0issfYo2W+GlO+OJzDwq1oSUtIJT3v8SR0FIH8KbkjV8AROv4nOJEMa7v5Jn4Ej0YDgAkqhIHXkmOwCKUTkEJBdWWqkOk9zLXdSGeFaTuG5oxY2YbsI8CtzS/BomOZCX3DdRBBauTP0mLD4MGniF5DtIV25U/oULyMAFr9iG5In5TCSF0uHLDolNKNvDxQUU5H9ClH3s9tUURh1dqjnBTij3lPx6TmpR5myr3zXOeDmCL2DjypwiWBiwMQW0GDUv1qCt6d0LXj6m8BCDJwjcBlKBZAyjvG472xjFnqSa9WVWoHhZOR3ngNyOJXcOt8NVVINuPEEnjIa+3MuvsMD69R59fQ9VdWIVhbxCcAxPkvT4Bhw5zT41uqG+md7QC3m5XZK2z/8UYa6DuixAg0gS0opoyzaU0zof2YzSdwt9odSsMCnCkeZeEmD/dYccbcjx23AAGbWoTDgA9bBh3uHuxJYIf3S1VbNjiSQmjuLccCgjbpI9Q0nXxdu3nwhVbde54otlc8ApS2urIKlaJW5oYWXLZ1MNGcSNsK1zFbCqTJUIuS2MLST+lHUgrL/Rlq+yD6CcV3ZelGisRc/ryNnH32/YBn4bOZEeUZDm4+WqlpmLo8BOpMiQ0WK9UfAY/T3qRgs32kukk/fkU24zVsa9rmeJ4jMAmXGroelR3beymoDEhL+pFsbrukeojJvQ38V8afoGXNF+3lJ69vN1mZ/q5Z+32qw76tElbnm4i6CHr5bkUu3Kb5pnTd3AxI0/BRzQR+NEQUa6MPI3U2ndA0aHB8GcxTdYYCiKxC4J1FaWVADEZAkRF/nWvpzRNN3evCUHPC+NrzdrtPtwPGfUizKYQpDQr60WG/HBUJ7oj/9Z232DfMjkcbf7rW5UrWpYQtb2VwPzDYlhYzyUcOrjeLxJW2lfT+q/gT4/8CrngPI7E4qPGdzW8ZxBpzPOCFvhdp8DJWQg/AW85nJ9T0AWwQ4GP9WaEQ5wgwbjU34cDPzF9draNik+SfGcGCYcWHTZXyC5A+uSAtieY5NltlQ1mnKQ+VkZDLVIiGCR7XQ/1N1H9PQQxIKQAomHyjaagIfGR7KsnmhqDflZkllV+HeW9zIqKA8vzJ0b3gqSNmN1mUgW/7KIOr5rzDxBrZ4ZW3SvG82uonrJDaxRHcg2yAivuwGisbMJcj7u+k+RPODfuC7Tq4fllne+phhNGflZS4aHIDzx4S1iJAZBnDyBKOACqoTb6/ccNvOGCnVJxKDgAdmjSKVY9vGpbFp901MxbFZGmUa+JpbIN2jqPIj+cDcfFDPLx+Lr4fZv9G0PnujzlEQnz4pnjVWDyDXRvvBuQ25Nz89BVl8O1Jna+i42zsK9HmzetV2avcv4PIjg4TeNLvTS6yG8v2m2PO7nnitATiglD5EQi9AOlKxQi9nNSkVmnbsDZbomPdg2xtHmjpUe3yyzzGa5VC3PESzW8hXOU2GEmjFE/aTnAi222UgOoatKjGtVEms9S30SWmD5Kl2uFVChed60yWjklzEChVSfEAfHU3vVL+qUU1I03hYKuXoophn2BMJ4UtxfX3KyWUsqj9s3tyPekyRzO7YLwOaB//hV2aVA2oZJj1yagDRaHlNLAzgXGf+ZARgIxZiJZQvvMxE21522w8bBtlQWxYqzCgNcbHoodab1BF0dSNJ9J5DbAglxh/HSGxWl37yEaW5M3ji5wBwXcHT7D6h/7JFXKKrRQCSAKujK+5d0Gn8yJj6BdcFFG04Vku/DMzBqA4oU+xUVMMyU2zoWoYpNebGX5ebs9v0Clx+fln3rGfPjGLC+g4yTULyMdQkkZfhJHUH9KlPDb0ryZw1S83sTqdpdwCotCK8dj+msFQeavJSImbKvWrdhYsCzXesymZK7s5/d1KHysy7OUOrc49RYi0g7+NtXYcrXm9uUDWdvF58iLPRq0sSPQbYZ4OS/OX3Cqye1aDqqqvMhikUYKFCgMv8S2dqeKUOM8RqMfiB1WeqCBXHOPpUj9EcDn38lRdyd4j0pcA7FLiGy2Qg3+xOZPAY6TW2qiuMyu6nFkOi68wLxX46lUe2Q7p86OhJyCsQ2MpnZ+pYSFCJ910WXpSYC92OCpxN8VBkrLu3K3sEWvcLJVOnU9LGXJW/GuX9CrqigB9J3D8a0fqidQw0fTUeFACI0UGnhKN+TqD91j2+p3pFurdHhcjL+SuFQ7SNy44iBfRln7R79gkhNCOCpEu+E8pte4yQ4jVGcZWPa26Z+4m4DitTULBgNWL4jh9SV5DndpLjln6xLguwnZPaI+34X4M/Wqj6PdUsfZwdMErkyY0f3SVGfYOJlWNrzFg2qOS80AsksMwi0ZG/A2CM64ZqHsIMu+US/vytx4d4ATRugn9TnHIWxTPz7AtV3YFASSHowm51LvPvNK/pMB9VRJLe5GpSC8LQZPnwfQmJd2DtRgjuD7+jQwpEMQDUmIY/hOa32w6K/4JKRwFNSC+rVi6fjl7Z8DwNEJzOa8vidhmFY7zhKfKyTEq0Z0a5oyFHXmE9ovOm+BktBmQJZxO9Qq6aWSjqrfE8kZyoC55MK/Ab85zNaJQozKGUIUo1sjrKWQngR3elkbgPnRREE0dd0i118iDwSPl6ZVic9hs0H2QjLzOTLcQKg4RQ+3WZhipFFmHqeV8LXsoaexMt+xmKo4rhu1XdYTvyqLMv1cULhez3SET/Q0iLunqQiI2Ls1ZQHjQgeGnvLw/vHRlZ3f6niyIUB/hDMBCn776tA7rYk+5WTklMCtgSmNDAicgv0q46PXHjnVNnjisooVjHY1DvcQNDHOiyjko1Sh5/iJQPZoTSFfKb1PydlaB/t8FFf3WjPyz8ixMJt5wFGEMhL+bbn80cwST+MC80TQB7eZ5WDhbKHl14ruqgThx+fwUVXDkXZKuSpABM5gyYiSt617gO766IW3dtG5xnHXYEg+L1xf8wppfR130s/gDTT48Fwr4G+QaDq+bTm0WsO+j6Mk0Xe0G6X2f3a7BLGp2TDflqMBR3Rc+QF16LwSfyWcwp54C2ci5i0dh8FoQtSCfzSsWlME+6PrBvM+dYyiMwKIY1Cjz0soKBqNc/hIXN0KQHIZh76EelJSwRYlo/PzxdOv45QO177zyG9X7qIJYDZ/QBgCFAfRH7pIj79MNObOU4l6j3+2kzHAV74FP5JLu1GiXSwoMP4w0jGI9eUJCwp/wWWVo9/CmtSGrLhD76KO2kGFZ/ICkx/42pHw9b8njoIShDOzRhHbmq58sVW7goaUxK1vGAGienyiaZfHzWbBywfNwTNVVUtG8vN/eezbbj6yqjsN2CnobMIP5MYbfog6dyfjn7awAtyhgG9twMOcB7sMgpz9TJ07oPeeaz9bwLgjp8FM3jeK374/Mp4GdMnE91lEePgrdMYli1nN6EOJ85BJHkXZe+jWi9ZjMUXn5HSQJDCFEE6ZtiyytBrfu3Jlhg5FXq1pbPJqU84IVqeFbp9NeHTMwZUTN9RF4nCTCGqFOTR3fX+JUagL1OwXgi2c5b5V3lD9gOC9wVFKuQ9/8xUjAuPwvax2Urt5H/zhZtfsXGlIRpVVuRpSheYpyXsUkSt/jZnm8LlDyfMN6uYVYJgoPQjElZH9A+KAeMVmMY7GFrBvxJ5sObwr5OXmoO9tl/U/eeQ5YbDWAIVxzC9XMz94FLVQL/ZC3uHqw7g96FZq2ehc3v5EUkCC3+PO+Kdapc33Lk1lGGJnj0ey2X2/hcHzP5VgRCI8I9nKfapzVhgdS/JfLnb+vFt5Tv1wv7esM92PdAMC5iFzBmOQ3+yQlZE4N8R+15VKBFHSsWSouxaXZBnAWY/Da+CPpJMYdDj54UMp6PB67aYQJdYWCLOfi8SldBjSrtsaNe2NJm0dX3tiRjitcx2aNdx+k2/E45qBPm4phacLrXLtEPNhDdn0Ywq00FFV6mYKkeTMKrFumuA+NMV8y2tDV9uudjneRlAfrdMG0A3uySi3CeXm0Y4C9BvfmQ2sJkH62HMecdFRjuF7CCEaW7bQtKAGP4u2HBcVe15ox/L5P+6C8ARibad53pcvj5eQlYulnOtUtODHOGcJqp0F3UmXdEKRn1oG6VhQ2kxliXq0dEQpnwen6Ln4s21hw+yBfsCwSf7BUJejAIx+yuYvQh/Ylu6x8M0gJrWH6YEts0XA39j+sq0daz5vQSHkQTbS9lDpPI2KNelAG4jNZEXrOlWd+WHwnRigZfipTTkQSCv6gFWfrFurj4zgpyHqCCNvBULReWkVWhBhc2+wfAwATQdCC/v3AtWyT0rAm9tE+RcM3wm2tVl6m1JW7+6OOh5JCr2sfN0FrpU2egIalfAuNM4SoIWaB95SLVNkh3dYaCo3T0O8GT7A9mymRjmt5gCYJdXaClcXGnqx/Dh0to0f7uPCOmdUJHo/4RdMalvPg+uJeovhz+fxrQxmb45TmOf6LNJ/GaDZvtjIz90VEtv+QuO4k9BaSnYReQWARACH5FGovoGCyfLCMPm01ldgd0dNB4goYiKH4LyJLcYQJz870IIXSiCinJcoM12uLGHWoK0ZlrOf0mgoigJLAD5/Z6wrw86dmjrr5T7Wt5CW70AQDccHzR79646IbSRMWv3ll72/qU/rEGB9D3V82bCf1nfj+Bb1KzBOEkYrQw6txMjxVviN1OZnKehFZr5BPgXn6D5u99zWq3KFlY/tVdeQOxfDe1GgQN7iI/fACUFxF1pbPqQYRVi/XoIJdOLkxIPQe7N6JDFuDmHDBwrO2ZAlzQtLficerguQHh7aXqzHMEwTkVCeWhvlDSlzKnEZpPDgwtPhAUmBxDsNyd7DhwMXqtXcMhdXScHq4qwNPsg/Jn9HJ4QATR1WDYfoocNhCp4C+vdFy0EOn6zCef/Uvqbgp54SXLSkrZFwWXAUS/V676LV2C/TGJ8zl0uouPnJvs+P3yVHVBwR0XjoZR+cLxz4xTufAylv4B6bVtRFMiMv/tJs0ozvmnpi0EX7Zsa8iGdbtdRiTG1A5V1i+qBW0d7dxwJZ7Ad8CcC7DB8j9eVA//qd44GO19q0tmsZOY8pflpVOgAdC58xmEVdxws3viqfeQ3ms9C1lZAfDEsNPCRC/L1U3v6tPnMdw6IK8ch4Z8mdxthevdzoZ79E0YXrqMDgc8ih2B4V/42w5vTG0sOSNpjsot+UNhkXbxotGGmNGLFMnJge7yis4pcKXTM2bUm92/W6W4CBhGgKSlqypF+RxAwrY76DeLBt4mgMOQ3/sWfcKoAfTrjPIchBfdK6ftet1oj9FVZ8VPcTQpYoEAmwtcpaZyu9pVkXmWuM7sKDCcjQaYNOoJKy2wQm0iE58qv8ivo05px2+fvlYGZrwKBHpYaWQCRjvWbdLnpxTXiCelgtRGdnGeOvI9iTn9VGHQnYS3oUXcdme68FFoNNDf7MMkPYg33DdlzPN+nd5eEtFfQPs1tlTjQSaRCU6XEyshKB2gDoaa9N7ebW+jscQSx93yfzNG6x+tVm2zbLwCQJlysLyxX4NYYZmYSaYmukBuoRIEF09+17GmJaEYq3az/7z/R7b3EYS6Uto+XP7AfrcjMNOgYppXjY4o6hmHE3JuU2FpgWdyhB7GEfodv5Tk6he/8jj49ZKkziR4mnap42i/O9yxV7jumoukRrbD+/CzqgarNZ6HmiVhfxAeTl/x9FBKrPcdsGKv7tKd5TKeRRqHllGUpOZy6gFhn9g3kJxc6F245UgRgGLcqfICrM9zu8HXcfD5aMPJl2+o7z1TTovMjBoHBEcyOSQjjLVh/l04UHbatlQ82fRMNDSVwuh6JFJJEx5wKDDx2fI1spTV7qiXYuf2Onqlzh8SD3fbYVFiHNbSCiJ9TbK1yRNArtybByTNMBxWbWZmazPB9Hw3JSDaq7mRL6pIIrtzkLi+VhWQcM2uP4OHEE6m/nducQPBG2agLbNcp+3dRXsXwhnbOT5lBBgFihiWCb6X3Re90PoEIm03GffupAd2//rOAgT4KoQhkAPSwgXPOa3puKGuXjW14l10Ybt8GyIA6aTxAiayLT1wl/PNU5i9iBRrlDYUpTkGZx1qHfDvkjXKvG9Utq64DnRTGrkzTURWc7Ncc7W9bmZWO9U2T8Px88DcBcOByvC933TXUrEiTB94BG3Yi7Lnzy/MKza0rWNCdb/1E4p8zdHQojWv0jBQtvrffKyjBhyUtMFFE2NXwqNW63IznrCTC9YMcrxeZ9a3zIozKUZrEfPwBhKXWFXLHK0QaLrGuPDltfhHHy5FRTRL9D84v2/Z2Ftd09a92b1z6GQ+NR10HCaWG9U3TKSuv3ZNo7w0cET0yKe0Q+ZLDs7UFwek3/xpYhiDjru+rr2vXVj9rlslOedz/bi1e9lu9aopGzw93VhQo2/HihkaPlTfcXjvyEYueE0DvrOeWCWDA2/Iwdn7sKRjHKYP7on7RuZ4o+WMr4VUP16CGSnC1cQWaz89Z+FX5MR4JqOTXREGYUqsZ+jzO1VW2gnOgb+lH3k72kiHGIOrEg5AJ7v5eSGqSYM2yzgRM354+js3Upy/pYyEirW/5lH+jSUawue3b39WoYfK78zda7xxGQwjS1w1pdEGB1ndeBiFE4VlusOwDzOKA+3MhaL6ZrlLbMfl9K4HRMycLXwI1EfI7tx/kFUmPX2W3JkInixssRJRy0jCqgzF69A7F1wqUhhmOYQ33N2Oa4xSDFZlpZqCyY43reLjS1IhGsqBsFilZSG0mN7xmfz4QXZVGjjdkM9tXH0IaKVr/jZn+cb8G5xAEMYwY9fW53h6YxH+0r7fHInfWnlhi5BbkTrXdnzMS/HllqXpevPr83puNqtih3hN98nSW1dHnBtrLz4NKXL/inE0gBLJhMGhtoJmiTcXd5LdQ214qTb5rk2f3h5SjTM6yfrrieXhMvI2QndCdnc6fz3657BdTY6qi1rF8D93B2f06P27w87ukRq793wVFCsxhKk0z90/4QTgXujdrV3Gd/Q144gSS7cOcAIxKEK6HvjYAsFno5sDr6TZxpErwYC231yjcLTNQhTekvAyO5O7PJGa/aVlLWf962GhaNKV2CBOCO7cqrCvhV38SwI+hRTMLPAtwxPuvC2IUcOxyU0/tU+agXaiT1qp/QFXEuc0cBdTDZZDXoRD0KGg0hu4kNoPWQkYSzKLc57Flk6sZjECtM/Q9ZQN1pNXpC4JIosu7ox6wfGk0imJF2Z3WGjWVniziBuiAhv7GHdwrLj4BkLgC5DALSmP4pdYqK0muCjGvB/nduSfkoEqEKFRZBQDWcRmhdV9vDd3/kZXoOZ87wGDf2NudYFeZeQZHCP7CvkTde67dHWaQpx6+39wGEhNI9hsCD77sBWKR7PgDXKh8v7J8nVvRWL20h5b8a1f69Ji/tP0aV7mBMAAiqLaS3DzDJnnqQBk6z96GnPiP+SjOIQiGqL/a6/vg+rcP+jZmviyKtwOZWVlh9zCy27bxmsPuuW60bzIF2Jd4kZJUSdjZOAgQ0a0ywcG19TGuD155O48hB5nFke896+rqm5HyD1tGDqUCGLG6MQuqDJ1/Tfg0IdiLOlvQV0MRyF6/75vjD9J909t+dtzwm9OXmTiGoQmpu7w6ZDd6/Keaux0ALHOkFezljZAz9M2jcYW7RXI8JS8RJ24xjqQUjes3L33c1wwZYdge5K8SyXjXURmcDyMosGySzFOzfwMFH77AdRM6X5Z3JO4FUfLX8Cl8+KnGzOisaTD+NThrPJGFfk/twJFqOPlmBM2rhuxOcpubpPq7NCsZdUNlCaMPa2FEAt0Bxu71+MA0lqOOe+qoTPjw3wrzzEjcDkMAiCdpNGt+fFGqvpOeJCvcWsvfE9vtX114VbkJxNOwyY3lV84YBxVGXcFT2rB42XieJw1+Ee3do/43zt+e+R27tzv+hIqkPkmSQtj0j8jFH7p0qaOTxShCSWrdFtymgw0/AhPrytybcdpKvyb91hgxHjAPEf+SMRm8102V4M/zEnYgkOt50lLkSAY/uzVeRYx33NGp7n21+esmx2dgEBT/RMeAZnmWl5mPSGnOY66hn7nK3zhSqYZrJYcrGZwRqXy38RdXo4hf/R3V/hzTUqghbdfB5ku5lM5AQEJWgjdE5wVmu7SkGO3K8gQMx2HgHKuxYlN1HO/xSH3qBmVDBQFBl073TV1gtFXad4snDK1n1jvLSFTBbRmnqgEtik9BWFYVlyhevvdbRbEPj7CUxN4qxj2YZQfOb0KTNzI+MbpuS2KfDDeTF5w7Z43aePYdyw4RKeCSXBgc/0HANzWAbI+aOXh8tqKuUv5nHh7Yl5wHnbWtmyluXJR9nl6GySmSmJThxMoIzNIE8USCCEXhwLiy0aCw0it7OnFa0OTNVF/jrhL4ALfXXeUMJT7otK2Yy3zgROlAF9fsjFgiIqP5KUqrqaJEFtHlwzn35ftgXpe1QlXf4IpRz5SPqOCiLSQckyMSC1rItPsB1VSNRLQ/faLIqwu3PveUUp/XlQbWayS8NwbtJmKCUIJ4nOWRXQa4trBZgXKsCgGEtxgnF6sLzIaxx0P064rHBSmPMgVsTaQnHzEkHwD/lcAnCc0gEd0uuFrgylQfRAIrTHSFPSME3AzS6AOSDmGqHA/O45ly+zduF2YyXvTluM0gufFyyAPuMo6hirqFWjhGPP5J8cNw/f5mqJ7L75YQw+JCAqWRDEUZsu1eG9o2VFBcXuGC79Cn80QQScxnJYqtk5ZZiFFRBbqkjyYRRv013HrKNMfdZJBIuLkKVXtEf09mY0uQ6KCL2oFrSoFf2ch0AU7tOPWbIuRvkLPDnBGwKyMTm4uVy9fGhl+EBMD6pnK2tDnn5ZVCHAp4ARRAmMfLcNstsbnzk3z/6DIpKeGwlzqgWOG/WXxI+YPIzuzkpJXXcr8967e/aimyU88gR1nQjBfx4Ho9JlyN7CxmPxdSk66UnEreinA1N5WTotaubh2MXNBGw0dSPcWwBldHGLDabzMjkuu6/msGOw/cS92BwaNMxpRs/suLlPtLjXq2dLqj4zv0snQYhg8X2pufzyfLfuiJ9mednzIK23MXqjBd4/sXZ2I6VHa85sJSVZMcKvXWp17/tNbhLrIVAWoZf7XnkPnoFuvkeVG8XqgFih5KCqJCc0WR9yGLDrbD+3imoH9moR2xa+lUnCoR3LvlpfULl/RrQ6gjpCtz4Btbqbsngo11t4cS46mwkWc6L5Ckpi4zfal6QvScDWdXIFr4Oi+7NvAQSfL2TZYbO/jLL89SOaIVuoMnpQ9FKwQcmZgBRE1hr+pbfWWmxGbKzfA6HxfBp9W4TwLwTnCzzG2c5PenZv3W7Pd+2ajJ2k5/cR7zFuSZ9hLk7XxNVmYqUUxvCGrXupUzqEf+luf5ncjt+4Rd7ChHkTkN5pBzH+TTqxb5SKnUVb7YMvRnMvEvPtqjqjnGw1AfPk+tddMgoXGp69BJS/IiIDEFWCXcJ9m2hOVkPsxrV9mXL2F2RG8Po0LX0rq/YbmXyNgApTNm+kp75SMx/gU5tH50btUj3jiNr9LaWWD/GZLnVvitY1BjSav8Zbj2iI4RNOQIKLiuFzzjfZxXYqNHbEq9LWftdfdlOduGiehfkiNZpN9nuby2N6zCJlZStPbw8qIToycwwrJyxecl6Pvrsn97XX6xViHJIcmPZGpy9onfsDZGZyafhneaSWw3hHdZzCvI0ub59fML3Xh2Vskys5MGF1zRNF3RfMfbvpOskrimnL88iH44bpCRPTcqas4qI8I4SJXBLbRr4goPzbv5G9iXibWgnjW2MKkj6IQuXq0dCQyL3bpXtc853VYaTzrgeDRF3TV6smTvOMNmFZev5+ffqXjITdn5XZKufLGUeLTW1HdGIisf+PthiYL8+6Rf6RND9jSNlvvNHiLjUjMdS3O0teaRriS+5Bi1FuHP7/lMlI0OcqLDstc7ygxK8aEGd+02aQJmPJ2+F+lNn879uBTbW0u37+riIMEFTCfeeIYZ1KYaLnpHM8GWY+HhfE/kPBRV0k3tDGZSqterqWr5LOQOcwbwOhVkVGxKh4RrF1sydPSVrW8DQO0sHLPlhDZIaqpZJTi/cM954s8MncDygRjlGnJTKVLgi95u/CoxKRfhvo7WcqRwLzsPKPzBnaB0g0kQ/w1ue/P9onOg9C5Eysm0qFAWpiigIUF+gn868vfy60xpJE42G+OnvtPjfPE8xzmQZgfhvYWSMuaAi0jpxIadjl5ufWtX+/J+JS7P8Xa+4vZFWZSt91bif8Lyzz9Xg5rmSZbk65Qj55BZZdMVuuyM8NhhXQvPsReLFXwnXHxT8vwaMaUt3xJCL1BNEOKlBFLWOzIoEwMLLzuuGFpbsSxTOr5Pq7RB/RDBLpg0y45D+Y2X3I4h6pfFDxQkunq//5uCg0ES6KtkmAibSWYlv6W8eDATMabwIguJl2QF3CWaKT6EOiWGQqxjv2oR8H/rnLLH7hAkLJVAKCYx0royt4zymQHzJsM2M1W42DhJOpjYKn/lPhOtIqpEYB/2O1u/6YItRYhW+b754fVaDikA1XaCav5/qq5jyVFoS37N2+PNEiNAeBB+hxfe268frqpjZl5Hm+iuagldkyfzWDwOMbTOTBAzA0awMHpRVw9WOdhX/LHYJZ6ec7yBfaH3wdzpt82yvjfiDsH6Sr+fhyfFOftdjfD70hBocsP7W3OimmIi14D2vWhwzWzAQG9ebjc0KdGeujbpSvntWY5ql7Xe23F3ECWeYZ5TcB4YxJrGxCyPpnF33rywMjlvnALN6uNUscYZng4Zv/2ZDwRI/TU205bvawMzgKk3rCsRoeeEQn7dwsU0DXHc7HkS2mywq8DUOA9/3EO2GMtQBnKJ2ndB0ki37d9EgSOt+9ybtCJk1jO6nEoIBWW1z3wQK17H2zxoP/uy8y+GgiJ/8RaBmG/4loA6yiVtATmqPWiKUcC7hH1kMLEIfFfhHgicgjrZv79rAKe/oHned91p9WxJkVlT2b4qhCgD3s2kXzMqNCcXmnPn5oik3GlJfsNl9nt51PU2oLuc+PINw3IT3tdnq2dkL3Z/HvJrpA74yMz+wmMhJb6HrahFKEiKeGoNj7pmKW6Dj+P9OVd1yb+6FNjTJdBi+Tjhg+kvVX5safmmi66ueH6AKJr+wp8iMvLpdfMbdj7c0qmkxvRwvTdHDV0blx3EmhD3zyu+ZgIvvcbQOJsEAwvZXwOKB8Qh9o0dzUgR4dhq9ksazxP5GlPRyy3o7/iw2sFIv55GSHQBv6espbrx8H6zwrl7UphQl1l98OBfY+m5J5/biX0yOt+CAqngIDISt4dKL02TGISdJmFBryIuUbESoODIwy8ETwLs+NgvsIhB4mojCrXoZWIHv8qP6DO/t+mBr+EmmmK54aqPzEBEQL7eL29ouA6j3mrc++PSYgESPOsXthB1Go7+X/0oKm+IFItT9irkzzEHp4ksv4gZAQpm2Q2uNyIKEGyfR3AcXI9W9p0xYIgjLT2/n33pV91pcuAZZ42fEwfA5OQnxNwmCYnABNzXOBKl1RlkXUn6v7oNjsNhj0Qnmr0R9FEEOJr80rqyIMI2vxe/9+r53/jhDlb8G4OIjwSh+SgWrlsN4hCuabw7ghdLSNAq+4Msj2G3SHcz57QdVu3AzeJecdx7KBh6lQne37SRBt/Ptm2ckj7aMy10LuyosMcLNqXHXSiKjR2Iu7qC3FZzLPBucqguTdcX3l52TMsd6l0XYpGt4/ip5yUh+7bdWJ7SyrrGnD965YSzSd/3X/0ZKKL6bmdaNAnMgiSIN/eSfRMPHObsXXlZwKDaUvVqTzdcskWSXTdlNtm5yzpApq7oe/Pdh+mW0EYFDsn6yOuX+KWm18b5tSb9uhlU12aci2KpevmANvBte2WmDtwg45pgJ486FfVtYQryIfK7r3Jrb8XGLc/GRTXN2K/DKwZvZp0Tt9rjAo7b5Qpe5bZ8Dy/cODHsl6rLdSBHU2oaUOIihshzeB7ELPKXGtBsK8Yr1owRZXf60WoubzCZO82/nG5Bl10S17v5I2VjxZ37OYAC+FBhmgKeTTHPqpU128dcyWANcWBO2ipAXWkC69cmTjNBENEMATX63/o1pCfSVlvc56MahxRk4b6EID/X9GN2m5rBnsODjt4dl6BLSML4rDxYh+k9/46+8lKoywlhliuWzRKuEudh8bndcMxD9Ku/APC2gQS9s7rTpv68Wfkh2ExVVtx7QWp7KDk2ZJzbBIJGYGtLZ5izqyXaRxCb+ZThTfmVdnLchH4jRslzvP0GysVq+us5w5FnAfqxt+yoPlzdiuAL2AVeiw/sN8l8416HI/jS5ZYGTmTMR+EsHjGKAp2Dg9IV97XuD4OdV7PsMD5t9NIqECUSz3PFh6mOUgEhHFUZ8nGbCZlDP1reW+XrqoW2QQxXzIceOA3fijG9T0dtekZyHQeHGMznAFl6WDXAq8g4BYkENdiKfoZDEjzah1B84OuyplQgCbtwEVehRPbwTG1A2eALYfFbFfeUxtlGqxeMdHbv9jcr5ScM6zafrbPqu7iVSairMJ5kkVUhBvFSyYZvETguHlmVRmNe3zoMQRAaPSIxR/AWrwtK1v56k+HTyHuoqxDlTaXHfR/nMdXTvoGtKIFXxZ8DGCd/yWC1oXlljTPbi4cRSERQNIrGskQhedrEVz9H8qxEqf7+ql1ny0hUA1wyDePWF+ZNnKFLWcBKnNrraxctFx0Lg4Nq9xDoXJPRso29f6LsU1y8KNGURco/Smm/mGz4CM6vlNQhTkm5CO7Cd7MlED9bJnWjUT1uR9xC8jP4vdoFZUlhgqiKBH4jz+tLm28euUs51I1XVNeEZYni1xWdF9Z1UTbDxt6qXeB9d/3XmAuPjyJ60xrYXMyM+cbDB/xhK25xb1M1Ze/96s3hRC7TN4KO1nKQajA7ScydtUfHHTfcFQRsV64iXUUT3WrvkLhvJmL8ngjDjlcIMXfqxUFC4tjYQ1rwi/Sfhjhmso7av3yU4vmsDwPL54dIcfYdOCYK5bSxcN0/r0Fr8Z9G1535JpTWWIOZvO2CEu/lo3IgzQChuwFowJmsbDTMJ9+hHvY7x6UpL8R9DTULBX0WogykVuJNB/t+jts94mEO2XXIBYwZSw7b/HK6H/VhU287b1gDXOyHqBFI/nHti4ciLaaxpdeq4waJjawcfl2P/FVtmyEp0b/qwr85XNdNnhVYmheGD9oy1RAAJja7Yv62XAKlX0X9F7wniiv8daMEuBdTFAH4RGVGeg7mIgl9RHu/1C4x4kAjWVYPH0tMt3WEF3BE1aQVsegVoTv1L+K5cELWg8XIccFFZC31VnGcC+UGMpARRoUj0ZA8Gn4eChwKWZvsNh40KjXH1vJSpruXVB1EnIz3vAUfpRNC1B4pjRv2HoOWzGsDkR+wPPhNaBrbgRdvrr2XgzDe3x4mclWtStQBKVxCStYFMC6PsCztMoVz+jfi5nbuXWvIh3W97q9a2IvbsP1ZUni8+qOVybRpM+99L+zPd6NLFLRyEQ3CMWs1DKzo0TlB8pnXx9RhFvKCJCDyZovHCAajdkmscXuGdRK8E1TOdU/2Bk3JuJs7LjdklmB16NrROZr+7O8q5D6zW2C4uqAA9rxFF30IPI3+muyc9r4Fg0laJl9O5nVXsfLi8FfAsIeNNyGFQTruBSe6fnKZosK1ILUJ7ffsV/u35ptsYWX7aH/zIhFbHUisT0gxu8nqr//KwlgVl3y+ZTicNS7wFsGCLuzCZ/rs1MI0qRDUode04MZrzomfhPbydrsK5QFaxQZ2nabR0k17M2gVsiUv3Gx4mjQjMJfFvRJL5V+PZJko+cTHVIbESMApKg04lTGa2GHVPFWxsy/7WEuWQjTbv+wUb/maOz9WBaOxggRacGeUoTGltkfkGUvf4f6m6usLeiz6phlP5m+48iMKwIm4hiVk4YNLFZ40v5DGL428sZrYloqriHv5Leab6yjs4Yw5bz28vzs6wwWnk7WPgsWpn1s3uSbZ55UwpWa6HMhv+kibsZeZ93JMxjnt51ko0dnRMAJ6+KlMlk9y4YopRd6DWZdDjgc7oK7xr34C/Mq2Odl/FZmvbP01FLt0EpvzpJkjUq8v8h1yiSE3xo8N3XZ1kuoQItRAbCXE6PvWQjyL07QuZuTZZAF670LAvwbmHKMPyLa7AoGauxTmNHT4diijwzHLN0UkoJdTHTL8VkOnU21OU7LOAr4SByaUR5dkoPfudQT6OBy6fVMlvPuX85vCDAAkRj7xp6aVLwXrcHMfDqT1ON/4K8nNCal7QUBBTIS7Rp+Il2OxhGCxkOBmJPVlpNm6ZgpjRyDp2BONTeyq1pMPScO4tsfwUeP50qRYDr7Fwy9M4qtNhp7iKU/m1kOJlxt//1Pqn19eIluVSarz9e5X3U2qgfN55ySAJQ/M+rozC4DhQ1VlDbedlWVL+p0DEzPUV+BE+o7961ZCAJ3g1zZZuS7VDW7atzlu8XfeXiroQa2JRCQBz6srMZPxTiQWTqdwJ95H9MAeFgIX1F4ja7p96CxyFEvvfrJTaMGzZncsO84bgRF5EuXyigmZhdJzswbv4bZVpitkUjBQ8PVYw7eFQjxAuLKsm9JI7tobgzPFPmOTrhV14UWtSuSw/tLhSBkBpaiBtBZQj7KJvRWg9kjAi71431mvcDQmYAPHyHPQlvFfzDT9WE7K1yHGUWyQLceDg+4H7MdIHPZNV9v5AdmnBxR/oVyiKhT/69YD7a14+s1dgVmev6GQi+rlKEHnO68X6GO8ZfV4iSH3aWUD+Li7AK+xgEoR0aFCQYgXnxWal5EmXcYUMvz1iiICmMZ2gBdxTChRtdp3mbM9BgooBcCASFKN1zwvCpKovglKcM9FGvYXH8QsGVMncyJGkkX2dZLQuEBu8pKsSUT930y1RAaWDGfofy27yb/nRq4P53x8Emsn/3DtREmWBVyy4IPgsi5FfLoYH4M5Fp7zAPgNBzchbjqeAYhKfb7LiHgWVxyeMEo5TX4sjn5LcO4lvQpiQQjwHYJ37MbSjRpU14yOVbnO5kGwyUwOWxk+pTrp+Gs9JdJnUM/YGqAHcb1UrvcI1s4IKZxmA/r5FlQRo9fE62VL2cDMq7c5dkztm4SwB/ELKgBGNkcsMBm1kfX2nMaC7399Tt5kkjxiOV8wHMc+mANemc7k84WTKQN5JP6bHUmXywN1CStQmVV0ZSYRn00HPpci8MHOMIhZUjveD4BvhXXp9gw4D36EUdX7AO67+CrJtRl+9e80eBdWBzU+PvOm5Q9Xb2L+YsfbUt8FDZoG95PJWpXedSbvBDcQ+0IxR5vFFkwiHkdVm8GRvMXSXpx8pYScGbTJYg+OERcsMO4UhQVuu6ebJ+X9JRuv+d6R2Macx7pq3viby41InqvA/iX5CztH7YVHRwHgK2EC7Nhen5waMGuPiV9YqzbP94GVkt2KjwF1x1q2ayXJZNJ7u3Kk33XsSBw0S1gkfK+2TDdGC3yNWoQyaoWSEh4jvqVCPb8rxYJzqBQQf/4eoZWn8kreN1iP4jFo58HWhkFc+YWcKlOdYRUxBiskv/qDEN2/r1LJ32xIosdRrpnvv6wOoeKQddCI9ypo+jmWM/48uJnB37loWsaRZxAbaQKMy0WPF508Hw+82sG+5uoA+hnmBzgIeIS4jEiwh2op2MRSb5AG31j13dTNwVzqJ9YK5l/rb9No3lkHP2/kkDp2Fp1ZnIZE0JVyCKIHYnnzw23qlSz9FQWPhPZlyp6PrtinOPazYFocnnnDE7/GGbg/Mzyh7PTRfhIHJP9pp8ChnViBjT9ZnGT2Ti85mRIr7YXnFS3zPcnHfLvsI15VBDYZAwhqDssbXWU/CSpIcodtr0L6m+BMl/3yiR85aGbnW4zQpC1MfFN2icuzyyJVCOd3RKO1Dsdb3lhppCG86fkYrRoQkSN97IGuBv3ZAFHnApG5zCanegiA3BpNl2x/pTeKn7P8MViAuhQPrcC7D3cNp+d4pBPfUwvo67UXQL6x67PHFoq+su1F3jXwTR0lNbwN7LzuMA3ZkMWTL6pRDKZ9COYRolyxdalSAgiGiiLTtHqKohrfpgDoIWIwG5oIYmeHgCQ0jzkGaTgu8GE0HG37mLyrLMu7KXL4yIYCzlggXCaAi5lpG6J78R6MSJbpsvwG5miw8iMhGxDHuj47QRBd329jM2KHQTafBz9+VULpJ+9QT2PthL3SQ3+ZxssuD4JVFfPXKL+w3zOK8UglQTtSayUP128VjT39Uv20Hk1I2OnXFORv/eeOGbZn0zBK0osf50b4F5bM8vQOQvBpsKCv9+8dF69z+K6okOc210+hh/W4XxKmb3vB9F57D37TeRngEgYYqFAdb6LcZ7Dq2Rc0rgOTyVwtG7ZHDJ2Wlh/594RrCU/7X3FtV1TjjpwoisLoCSdMNIFH0Us8LWGEJX8VuG1Rh+DGfFalY1Qtffmk02/2STum2XnYEpDFxgyU/0hLG3dF0XJeFp2w6APvAAgXdBhSdUYhaGS0BuM+2XTUXfpRf2HyZvD9fPIokoc7m6WxNS7yWH5921HIiwAolEtUH+bzQABcOJ4/kGu7GZhyYYrCKNd0zNNbhqpUcIgE/9JIIGAZ+Ag1XRRAdj62N0bgMMbR26mgbyZtJ1I0+42q+A+DdkyzA8ySnqvCC0gREG5XAFUJRDCeZibO3xl/GwcPVx3quhhDYFWhJmCHFinRI3oGJgBQMPCsNCDcFPfSv2Hm35im+NeaNwmbWlT9JVqHM8xp90+aP39dejbSa8PCEIhb/rChgn/s/jqS6QTJSIUSaOtE9j4zkI/EGffnhr7uvyzLD6OuCQqSA+deezgL8YWuwyY+tfrppfbdV7lpSUxvjVRI3mr6mAuZ2pyu54p7zBCVF4jvgJ3TbxZrJyOkU/LFc3thnEzcEiXnrVIxkPOdY+AggcgssLIAcGaMyPTeIcmhv27FBkNMBSXxaLsUJIrOrV9fAzmuXsDse5fAUt1Vz2CAp0Br5sX7jwSJojAhvezZtB2Ax3NZwB+bLQ+CUylDl1plOivfXe4xuKQYvU8BQvqpf4CJbe6xlY5VQfA+KGof2x84TU1LnJI7KYANhknTaYlbI8EToAbEHAfPLk4YiUxwTUyTSfMFXNJa78qTZuhGrRkMF5e3uUJYpn8P7xWTt+Ovzrj5xVpg3LflGiCzxfnXCOXXABbgNDxN7FtbmKk7OAlvmmUj4jN2XilnWbgWaJkoYxqwsLWjCbmV2tLIiKjSYPbAPrZArhHDzaMD2AsLZp5nQ5gw/YL6qtb//vKs8eCL8Q9V+A9o+zoZS9WcMWVN8syqmuYMNCc+HynE3LnUsH/dRbBnd8ET7oHaXIbkqU7vXB8Zw/y4d2TqXyxIXlGHH4FXKWVtou4JCzBLxQ1cCYXrrKJpOJEKAOlCISO/Fho3FPy42g7W4MxfQpKHBKhJWL6dGRoiZ/SPnJwdC+P0zztMLyxRAmyDWlzygHPU4PyKo4UzHc7mdGVIprLaENFwZQrhN0oHZBAhxsMp6gcOfCLNFtbExp2jI/+tEL8u3xOEvCF8MrDCVgvjcIjSMRLL0SuuTZi+gm4zJaWdKaWsDwqpkCvkyOfcMZSNb+pis3gKnTjrMx0i804LK8PvuhSWfLjucgZ9rAWwGULESIITLEneifl3f0v0gqEXWNO6AcgAXyTtXjt9P1+aOtAJcjSOc6Joxg/5qCDKukgk/OYj86/NR9iJVvdYxsdcAmrvbF0VbI3xQBPD6xKz7KD3LzsEnqOVt9Ha34bB/V2dkbXJOvcuIOjbelKbJ5UzHKqbRi1gEIvYMCud5JZMYTa7NXbynqqiNOE2Zs1UejMNJ4PYcgcTbOE+VhQAuEJD1wp4DooL+EKwOPQCgNUWJEsuCoSNZUPGhgkS3sSLARhnF1SYRUuCBgsGX4EUgqSHZ4/jFnbHgvg6qgQzUDr+HSsJ3TwquvecpHo42H3tkkrP9w1wv1ex2XaT52q2B8gb0WScKupmEh9+j1VqWDPrb9ZJb6wDjfCEMsBAXFRW2jy6vUxdCLtM6qyUjorjn2xhsUcLilvOlkkFTv/VuUvYMDlZGTh8QbEg/WobH3HQ4xUFOjcLS6BFIB9gPHgZaTSjip67fBN8HsxLlo8KHAoy0I/ucy+uaiAm5CEcsgnqu/N6k66gEOhb1IpxNGmNz2gkRQM1z68xOi0inMSSV0vQuEk59FjQXBtlWteTQNyuF3VFJKFDjTb4A/xluC9BH0NyBvd5uW6jQ3OxEOKqS4KvW43wZpwO9wv+siTNzt+Kv6LwF0kh4/0aCRx7KPCQQrOyZk7qdCX/Ju7BLSTGTYXsIaOADJl9FVBzUnzOs+qUyIDr5dpYxKD4/NKepe8f66Wlo/uwmIsp9H/xJVchDzqJDKk6bSIuWM2Gas/qyw9W6k1LfVvz4LJeNswvOWVHkXoiz4pU+CKytKIZWTLSAqYF1B4hcGfD7wFlS3fi6RYAU7krGW4DLSvxAOKguADGIW0CwgJ+9tuxHSrDOFbX6HKISawmiUI+YbCE095UHgClA8ZfEl2cw13ZqYMkOJ1feYG+dcFJKvHhahgkZvL+pXFcFo6OL0Ub0eQ111GFYm2l1IoPM4wi8HREDw7j2FUavBsj2BkQyuf4+f0ULmWJdLwWG5mdsrcgwyR19+SBN4Efmm71v+IaJYmlM/LNQs6oBrW5CxZl1Ba4Dmssbrf2vnlmM6bmA9JxWYkFo7oEd0hw0rAgLqjf2Mdnu/A7E7N5MpBxen7lO+G9omR5Ex7NvZ5XGSi6/Noj8wj5uPlgKcv7qB4up7ah8iHUXyU6kvFU9uXK+l80n5d67hW9sXhmbu7mMv57Ol+Ie3fmUqeyjdnRWtazN8PPBTKkcFqkYYBJtlImQTNX77TcXPQqnARdZ616VNvqkIm45vHyXzbeW9geKrZ3EddbOTtIX7wVMvlL4BLzrAR8EySd4PnnTKyKdJBDgbe/cqqldGE0SjVCfGNKvb3Dc/ap7PybiMCuMPpSvuxD7IFc/rwg5Wc9L0FjJIP2pvMhYs8/kJbEBZmVFa98ccpYXbLR035NiXvEJbEoF9iSbq6HwXbrd2EVT/vIfdjA8eXO9Sm7ANfAyYS5gY8S8kE4RloengnkzeHxfsmoHchw1g5KY3FqiNfh4pKAW0xUeujL55U30HugZLT3u2Nl/WPs1br23JxVwjrgb96hIzOdYNgIystY26WgVS2Te2FreOi6AmVdtYf6pxOUu5VpTCrrFziIs2OqV5/uK0meg/fYeeJSK1zXsvHRisTkKlDVnlLXFI1DAP2MJ/bhTvM8o/+0SZlD44EY73m1HjmbDc+N4svukI3LuaraxmaCd8tEhB4DUT6cyalzhSjNjwmBIEfgqEhNDpppKfqXYQ1sQESqgAL2qNVDZaR+HoaAy4vvW/Ba9aUP46rIyByC8rKk7o9EUt1tat+BGydhPoj1/eAu+NpXh4E20sDBR7bDeHCiv1OSGs5eIS2jcHilqzlOPLJym/caJaE3FMjPx5qmkSmIqmXzZkb4wotIe8FjWNhz62EE3rNm2ItnISUb/5bOQFP8l2PGvrHLstlStjekNifp45gbd7X1ixZEXJ0wJ/WVmz0dE31FqepoGtQAeZONuAna6AtBeAwXuyeTwQ5t7sJjYueo+ZvJmRIB0JC/FmCnjOWp/sLC1T48qVQUot0bTHk9Ms4ozTanmPoayE6ECUhp6GWhsMtgp3IWcpSP+K4GsJdT9PnK/MYL9WvAxGqpqSyzkjlIzzLtCwnkGrDqiRfp+Um2EkjVJqX7OsPEUqacTgE+JUdgOHrYikFi5087OcB+KTqTfZr6AA2Q2MlTWwxcJXMgnRaiS8YCB2ikqcyEEpBlvAaFTVKJVYAMryWmBUMWWjC9keUIugtt/m2ML5CdYuav3+jgFvCGF0Sd9b6DiO5dOokIzhHwCBuKat0z9E0Eht2Bu5f9jSoQJAm8Vw2NqWn/+jGv2fVr7JrqNE0jvvbLzCWv2hhmDJAExxwXwwrD35jctRAReypGL/NwnJ6x7NWVkPEeP4PIcF9CxXT7G9DiCNjLJlJcvWTced6ggxjjrqVX2SVz95l+QRLsS3+MCCQ43QNcMDmrhrkQwIqeoAyEBkr8WcjqiF8EFsMuAV0JdbuToY0LNVt2x4mjI/PO+42ioJr1xecxYfMMZmVDiXs67Ly2/KUZDu9sGMEaZQUna2/7XB7EhsFotkyOt/6rQhegsCudD/PWbvI7v2yKWrKdTeFODtPy1/AKTZf6tVizNBUnAmZbZZPqvFOWme0r6x6Gf7dZSzWPSFkNkbcXgrDUAVQLCJMGvTJ91ESbaHaCQUo3B1EglT+n59zfzwo9fwMzmBnXwwFZpGkUXSRr0rDisU56wHx6OeaTJn/LAfWgMfWLcnVHbHxlbwswaPyldTCSfN7aS/rG+FCa0AsWO4R5kG9dbaPyjlbDsX0TXeOKhlBi+IZrDGYqWQNx1UXP0S4pOfsQIWxiMDujd2xxeQnoEfzZLs4cy1M7SA19N6tcVGrr3nVhlMQcP8jRUpQDn7vo8F/R7L7OkjoxBbJ02C1E97OvHG8pABL3JfJDYtiAHp5V5lW+87JEBImzPRAZw3laFplrIyBa/xy7tXgM8I9pMINWizPjXksg3HAwv/eLsMgEbtheCW6agCkz9y3Gw0RE2hsADlVVl6iXBcGevOkQb2hZhY5IgoEX6lt8TW8K06Koa/cTomO7vmq7gMSHi/vmSiCJt4rdCmrb0GEh+GJoM+th2HuqPucx1AwLxKBflmNkMeVx9xf+56AUs5xRoQuwJORF7G8H3QU6qQo454bME71tqIkjC1VJ9178+km0RqLn9TMaSll0JvZiOqjUq8f8LXHTx4b38VqhffH0nS+AzkbXQ1nU+3wvvMvVpiyxrsUNRiwJyadwp+S7C8Sj2uUPug0QdA0E+h6l7bXUoR3ndnvlNjcqQJBjtVrGctsrq2lod0lsNXYaDwVvvz3Zf8AYvkqROqHdXy07QEzJNlk/K0dgcmTdDsCGaGa7uRmHtoU8pPpj/4VXRNjnzPGl0vCPxX7luH1mpv+lEuk5YjKQdMPbkvAi5syH9Ce8AGoX8sXPELRSPaTeKBunEVYehsOxHJR98QviH6JhmlF8EkxRdEHNw8hoCCzDMBzDfFyb9Zjnzpbk1GNzjQF4Gy1hSoGRQBAE/QwLknEJjAQBiThV4gp1/wZqsEQnUOZpxdSgmGxn6Kx5QJRPf/LtHhFqJxPUBpGXNS1G/Lw+cyXuM51bj9blznTjBakpyHGGCWL7kN/eFIm59RRPhiMfd27c7XhOKsW2akB2Z7Dd5hG8ulvSh/b4EDbpxrR4XLa06IZpkwL/r2OHWx2iTa7y1RXLYS2lE8kHE11bRzv8ND8ra8ScKIQE58HXs22xOre67KdnqPNsUxRxCUHB9Ens0pPSLP5S3gwMl5Di5/CK5Bvheib6x/GMguidEIJYf8Ab0bxNM4bDAVqz3XB5pfy0+RAZL/ZlWpsW4R2YpAr+n6YV2QqpNpIbGFkOJwRyo1UaJCXSs2hGv6JCWnhjLZa5n8iirksciOtfrqtblDF/3oX3ouqyfR+W4IAZD5XAdD1lPyCNzhjF3UpEC/zEnqgArll4FLNWbca5eRpllEdz0RpznnRooEgA3rg4pYfveuAJU9RH8L33Cg8YTVUCDkFJSgTXfQUHcb4GcRkSVFacRj7uxrpeQw2D6d6QDSVA/o9YvGQfpslH5s1wft2KKLr6rGghTn4YUr7BMkg/+XX9A94rx/6xth6/ybP2w85NyF2CIIgK4Gbp2gd2WQy/1ePtAGcBMLLMBSkly2DpZr5m3gpjRpoqxkR+HdVuShzSESOLeMwdATNqMgulLN/6OFvqZdOk1UaVVeqGiv7Y+XMBrNdAWJKf6SXI2gYE6ETa+cNkk+dIV2EGnocVVKz7R1FPGN8jzo79rw+9jUOjplWjBCH2SqoY7RDSwrOr7Y+l5t/6lbJyPig7YqaApE5sSPhrgGAqhFVpDBLW7B1+G1+CH86bf0fkfAGWeYEOnMNEeGjgrVKxYT0dlJPk2W0yvqPkKqQjoyfkopl5roDHjnPMGnj9RxS2LCTtxup0kXQIfmPnHRMB/fpnoBM/Wl62c5gQXUpT+MwtTUgyUFAJkPk3RpVtkNh2pTKWSIFB2PiIoL7vv9WmfjTHsX79c4Au9LLptM7TCDcH/XUVM9OZgs4W6RvRhopA/jvvMBX+WjG3sSRGNhNuzDDR3VfCYqFDZxYYGsQQz+sR4OCs4Zwp6dPOus/KFuFqcpRki4a6j9A2G2J2gN4zwkgt/Zs8tOQdeIf+0gpDKLl24bXPb6w3lcM47B7XFgQzPDOKXK3fn4QHbyB6dii/UdBVHs4uNIFpu9UeQhR0ANbBEqivMugKo/p+qfdrbU7MyBHmPqLrDTLTaVzLAHxzRojLp0zZZ2cdis6+Upi4TKVYn8/o58cxa1rNjezfpAxZbzBGxyWn6NjTvTCupopirv95m6Y+sLrnDmXaOip1p7JYqtXf4TGVU3BzkRmhgHqy/SygwENwl+b8Ei3gF+HXFWz6poivoiQenDfl9/aOI4SWPN2s4LShckl0u8mpX5jy9eGkhqBmrxW9yTL6sKElO12LcGfmqzA3wAos42oEBKMuDbjEVcRNDcuDABPJAcs6nmfPpKP8ILz9q+uj9f324xpvqaVZ2ZAUnaZJZRhtXQqPul+GkaLTwom8JQJ5zO2H/ZQxMCA0Cd6Pv7ZnBdkRAt8ozNCOguEtGZoQzRhEZI8Ky3UEl2/FYNHDFOU1SH8PN1bzzwdO/eJDuJ0vsH3X8eKqYxJJ9imrPISlZqHslYnECmky7vpTxw7XNJbKG+N3M8PpZTiRieXRhrRM+t5zZoCGL/+j/ijly4en06PXGiZD07hXr63tSdfXNRDyi+6p9+6uV9IHPHFAdcn/yk7wq0055yUQE5zvZGMn63ZnwueCpobsbdB7/1HqWF9QIDCfXJGXjPAGBJvzWQ3EXjpE0joonyoP/vLTnpokjmNZHeE0nQ185QcdsEXNRbmpZKfZEEMDSh+C1O5RasB7R9BzJCGtFLi0EwGm1iWMEnyY8hN4igbjDl22Lf36VbtC3INO1a/08f7F8MZxUXqRcQugnkOXOCwJS8FBPdO0ZJJ9R1aAHlkXQ6khYrHlhI/27f8wKZg0bfE7uvl5t+y4mVRSLNizg65cbz9rwM7VL00eXDH4PrBpafFHb2UkBsiktw6VwyTvFWSPTj8QEqBaGuToIivgYSxS9h4AvscYqP3v3y/Hp08R6ugSJlSLO4+W7MAsuQjqOB7S6tgehLkRw5QGIYcYvIYCGML6cLszFB97rOTS9hs5PwcP7BLGCAJDYQfQ5aRa456TPQ9sCC3+7ihrWtrnWcpUcMoXJDRCxVsD/2CU9psQfKL3CxFD2HhU2yrSZ1wyWLVgM/iap0p4rptQXfA1qAJ7eG3R7kIGTv+yFarivVYWc6uyNuHWr3DomPQbMUX6WGWvhRi65YrPpzTRKof6Byj/svkIOWLelGCH4JqyzIFmNV2T5t28qEw4/rTiVpK2cpTRvYl0gsYC34OK3WIsdVFBisXYO/1YtV9lxBImCWZ5pY5TFAXFIJtmTPCPa/EH6bE+5TE+qNHQ+3Z4dL1n4z0c8KaKgh8eLFSSqQkDtQXJCMXON2C/Zyt44sbErBXKblyXEF+Xv7znjJPMyW6OEYM+zPpsJ8on5/VKpdENpbCXOGXfDEVJ33Vxasb38xY95S6778VkAMKLh8PpKXudajDyI+SmBfrAW8JjcCkzlfEaaHugmHp+62VD6XUf5QWdm59QqQ0F4zpB2WxMIsYSnjKm/1laa2RCLR7u5va21gqELFbA9HXAtq9fLdK+aN2kH28gLE0CXB+smg81DWykWs1KwxQQSzBe4IAFNL7emlNOqWH8YmvIoyn0DuesglaRzU+0Dmc0CavrktSOm5EHu63O6LWpX1hBmOEuEOu+VDwcPAVSqNxwT3mqS98SmPHbu2qV7kBJ2HfhDqH+WV6FsrJ/jGZ4GI3IXo9t543KXJVYXOwEto9MvpBvb9cqykE1Q2z+WYC0N6OLrK0D3WY6aqYbJr3reZ4VpmbfhA99DG6rnW8rhHfket9v9ii5Gx3edOsYX3KHrcKNqcBe5sRUDH7gweXGjl8119vOhd5ZUbbtftOUjNaZzQ3vQb154H1adXkbobH3NyoVRZHBD5KfNnsxE/kbuZfyExO+gV8d9Il6JYxe9DKrBlcCt0crHWmW2fb70WdzwP/msoSHDHrfCjE8Y2DFmZSsLf43N4DVZ+AnMAFKVlBBTaaX+PKi+hg+Y31f5LlfvH8OBrnNqBDW3bOMTWKnqbv1+158Fs+JqFB6w/PV3TQ67ye+XePoZpqgYozxHV5lqTYvi86uYapNV9SSkPbzhSwJ8fJoWMz1OYqis0l7D4gh1aiUn/LV1ysTGz9LZrxP9m0EZ8s/czfujKkdgvbRHyOARtaI9z+tDIFe0WsQuTQMBu+y1HMr31MNP6+seA23XOWPuxVnSeDTAZOvnpwm+lX25GJkZ1A0ERm+1LeAfCYOtOAaJnOeHdWp92/4a0voD1eyA/RFWe5gPxfjcTE/s+zVNVXRYzAkX+9OtJt8f3M5eAJ5bR+KEqy/CTLq3tYgRvVr/SyTo/O9JSJtv9RXef/4TIPl6Hh0b36Znls6sqrSBNO4YeMD2LdsKA/7jT3RuIN3TKpFpTCEELyz+qXs/cDqR2C8w3v445Rgtk1+D8f52YymLnRUwxVBfXazW6Ue53yJTR/jUlcNn+B7/vw41EbpsueFoDK6gs+RvOzo2oXG1a1Krsk3yrmeUzJ6OCE56x5UU+eIQuD4OG2wNjfuoYFGUylr78AleX15fUBsfsYbyYp+c1BrDo82o+KY34wC8gyCZ4XPDm9c4z07RL8XNE0caFJX31HwdRdiYa7y/b2roW83faGARJMVbQGlO8qxxCKAPltGUmoqd5/f/nfezwptkYMuNhaxLrFBs+THIfrQn9ZPqoff0Jb82EbWLvpuqVSd/r+RkgW34t4Knk739+ILIF8+yH5hxcPIGBbh3dfhCQImggAhiYK+xUpcj1eVkPM4bnee/5llfeGQ31wMii+yt4gD36NZ7PEtxvvYS88eRAiFpV8N47v3i16UtHVEoeRL7LuZfcXmuzqA+irWsL5II63pLvcPw7+yRFp2H1EL4b8wbYQYeeXXYSSYOub0B8/qiCAlSSqXWTABfUEIWajIefkmVX6S06DosI2c86dOUMbNMfjDFMDVF9/139BSAj9lIlnHkjBdCULc0x1R0EhB8GRMNAbjcK6tsgvgFWa99tuHFe7fmrk1CvxCvguScNWybwPv7rRqrsi/KPQ1PWJarAUIBDZpwqWfE6ucTOrb9sVg1LJPA629UxAOmh+JOQ/VCL9yP1Zt0lSquVYGAwLgV4kaKelfO8tF49DnpcjfssXaeMc4uHlCL1rpoHlszmiVzYn/tcDaMckZ5fg340maQzcahi9qver1HZQw+vquKKcs+yf38zR1ExvDorqkWJtmZ9QI6iN8f4uzigl6Hefj9iEZbbd+9KhHFRJV1IPcVYEG3lFBTAHI16JOgVwqidUw/2FQ1+zcMpMzuQfgSrXmtMWgR+IPSw7BBoAJ0Bm6i2pfe7i3+XznZhSAj4VEGbH8mzTsuinwq68f4s5CH+16dkYY42Z9zGpZ9PaFFnOIid8TKVQ7t366j9w38kKsFSN7j0JoqI+2UmChnXk1X4fZxbAyB8ld17q8x5ABXekzBuEcy5Bk1/nlsGLRWAVfSUHLLy2Gw6ODQevJ6MOIwPHFDMhhxG5bQmqm97lInXf1i7rHHqkX5r27r0CaWPVqfj04pMZBXXHCBVXZRB6w6bMUxW+xdmckquC2/GVZN72glWk416tlNHjOEKV26fevC0tReRuZdXDYfvZiC5xty7POelGPPTa3zdAMyRrJOXMEE2LsboRzwhJfWFmDUAbyIJwfPZvGMjf/pQh4L7iHyltl9aDgOFUKDaV6bFO4mA7JA+VM6vEw453nYxVl9Ed0wIWuF6yRBlw1XhIa03QKPi8KB1kK4D0oC9DukwUScPLuvKhuhydbVLVn+MJbMz9lUNXSvzRPM/q993x12a4dcrIrNhiMvALboj4zThyx6TCvPEDNE1sfXCykXq8+nFyAhJ54rxN+8dvwvI70QCfWCLlqZ3YG8iWnzo2eEScQfsZlw9z75s08JPd7mBWZUNf3rtHetOhAGWARPPKppZFgVnP8kfz3o4ZA3QQ09PYAcl+GFXw87o3p+ZJcOInY2SEy66X9ErQTkpi45H0EAXra33smi5Xe99k9p3enlztS5lQ7Au8VdYdpbkg2WrqoGKIvBeED4Yi3OO2VEmfHjxKsDIx0O5wZ8sEfDJTpCFTSc4urZkBfIBx07l90Qz2xzSmUDbbG0bevI29SPk0IhwYtpAh86HuN1+i+FBcZM12ss5khmGDptRVNkcMAXocYEgsdz/YrQQn2K5jZg/mgcSU2eO7W6CF8RCZxmtNq4ak3PLdRYyx8fn3QkZtO+VGH3cLy57s+HVgENFew8v4TtTJ9zNjAPZgHLfNu552/BKgV+gHjerEio5SyxLizt+0qSBBR2zy6nEO6fgiFTnNsB51pWcDlaF3qiFsNoCqWZbzg1Y3F9pX2FMSZtl9t3vRJ/PdNEGyyvl4H/8mqadohcOys4B7H5LEu1fZszTQbO9PCMI3jkfvle1TlNutZRnpc3iO5o1gcvuaKThQbYrpUgNn8rBIOLk0Lv8f1l6N6+r9rnuZvwiGxiLzexqKQ3CCmE0ThPF7KAcC8HlJk6q/bDgzuMTLD94lZSYJaQBoI20qAvh8s4h2qw7op8+iDj+WCxcPCUZX48OFsyBnQvnPS1F5OmmQHmz/xOTvMwBr3bhbq9R5OgYNtXFqjgrp8s4Bz4ew6F/sRcu5SOlZzyO3eyjvsRtE4fhYCX8q/aCAYP1TDsd5CxOrd5vvNFXXqU5qleGwpSQ61+34JwC22BsZKWmvSaTgey9Q0SOWLxNiuIZpLX64GPZippAlO5r/c7cXiPr0KdxGa0Nv62Gif/yUbdJFPTCrz5vP73FNl5x76TyINcE2gZy7xIB+ChRylJQg/CJYhL/b6u8jqw8hxzECL7ZYfjbk0jLU9N5q9QYcIAEEHmS73MFKB5De2ad8oiSwEP4MvI71LL4bH8zfizt6Gzg86ph/nOk2zHVIMuyfg5Wypvh/4u5zf7cIIqMLW3WsYTOnZFniyVEsxehot9j8eUzPY7uNHyz/vD5LZqOAzzAPUSwcujFQhO8uRIqjjQpjeqJL518uQFTKYEx6lqQxajJEQXxI8C4f0SfIzIM4cewvvaPDkaPDDGq5Sycn9QEhB4OnlzhClS4q/QWYMHHgXH+8YNl4pf6SGxJS/MID4gWsesWsi08CD3umYpvMOOgqzLumYpY9Axgwuu1NiH13fjvmF85MjfyaXslBCiMQj3Of9HeojSwbqFT6aDy2mMvkiXlPDn3GDrXGBeZB9EnseTI7TnAxsm+C2QFmQJFE9ncqBDVrMClWrrNiE2BuGYBl1GXM9bE2T/jo5ONq4RxZnvKbM+tAljzAqLe4Br79/UUBHwIBA6SsSAqstNIigBOriiohD99AvHTPMA0fvajeacPqqkU7cALn3MOX7HHKf4OQtTmwGm3bVrCZlOZqdZIS16V8cWX19Y/IXhXgPq/+oGgDsDmB5G3KauGWuqYn1SFadGYNBWqaibb4HNeKSQ+8oz7mvX9BrBmcOwzAa9fam2R8Nmf36meQ/J1d3Ryuxfhegda/7dHY0zGPrIQrDbo+Qx0bxB/gOZurXlZiAoAd40WMVsF0522Ytu40b3srLBFxgYyeXqxmlEW14zQ9uxXb92/VWpOlQe+RjBf3y4w7uuxvykMbCZNRVXNa4L828Q834fHoqCnmdU2rc4v76Ay3dOa2NFqF+e2R6pIw4jcKVzxS7XH1mPef2m2oHpnVfIVdbtjTmjpTEDlT2ksJp+Kkygnqw8lCWDvB7saV5L0NUJTAvflUrx0CZU3yP4nCBRZm/R/Iwp2bZGObQbvjWH8NX9/1X9+6OEWPBYiMEAACbAiuO32LOufd7RnAw0rL/CgxYbAtcgziLZi/rSQoDZ1pK7y8XtxHVKzzOR7fAfRNJCvK0KTGDuV/YtTDEAsPz6Mk+sgpKOSJqWv+FKf5oC7p/zwBNeQYj4o9TrhqsnMcFaZR255/IFLiiR+dpiok5IVEY3g4H6P3vy3j9+fW6oEmiwxV6tY7ImSTJBiNGLZ1E9LmT1oa/rbXrxbD89rtUBo78GwvA2q/KiQmCfMSRsb4ks0Y0ir6yFS13k3VYX14+v3rTB/995bkrYFaYqxORbtOStMjzTEJ+BPxFfc7RmBl7/Ydr9qVrwoc3KJIeTAPG1XlQu5hRfxOSnh7x2WsZ1lNi6dXtY73tSfoAXZg2iVoqOt15zSjelMXmWOHpEveRGW4Koe2X6Vb6CyKLs7SIiHVSG9xGgNcXr2oIXuDasXTdpjHPpAnFFf+fLX3eGDIwk9Za/go6hDx0zeZ3dj4Hxh4cgqvd8js9jL0A3gn/pN3Q17QGChNSfrlEVyUUwpInP/aiDF2OfrqH/kJgYDI7E97TGxkPWB0H0YQuhcMQ3Vsofe3AmWzexW/uT/AchAOjFIGjjPOvjeuKwbggY18eI7qm7371oGgyS5hYTnLi5AXDsVIx8w/+KcHBiUxXiwPzMPb3xli7dG49lUgKgu58cD7/TTeWvSSeVVdImUdOesOqbZFTmm0vpUhCqttj6aZ+vlza4CA6kVjIm5R40vew56C/0p0uSV9+BbGa668FmHEh9MDqPj8FtyqbnBlu6DlSPKNz4ACGJWdYxjdWQvn8QqhO/evw92ktjl8a/57Sb2g/ZN4RKC5teU/HGpq5A5pdXb1ewMYDPZsXxU2+ObxhNflE0b5hie1XCMrUeqxdymCfNYNH4dbbo282XPl+NBEoRvpVL5T8b37BDRKhWBdjtgWjafo8+tnB7/DWeDV0bEwqNKdSmNuRGG7xVZhImcD8S3QUgKqhbzhLgQxmyc6qzZkZYZwN6CVNU+hEkK7rfyO0QRXH84Rtb7xA3qI638dRsutj4tk3VbEhcjWM+/m5HzzlYpZ9Z/ntLCDMIQnWwpqePdpu+HUWxvZbBR6/Uy1BKMV1ykvlJZA9N3ufDuCjjlPEYhJFf+V0e6B68WNexUMXjl+OYIDiYHyGgMn79KZGkv+AmZfgbqdnNnPb63vrB2Es1jKcJWGcNNBZdGC7WDUY06C/Eo8+Lc75KC9ES6w3cmFGGYBnqoVBWEPe49jkvgk70rNAMnU9Qh9w17WLT6qOwI5LjEqr3FvuUWKgYxYfFX+g9s9jk6PJedrp3KXJEj72NlWdxA9E643yzv1d0jnOYmiJqyCWkQ+g+iS0RMxDmmhaN+lh9RBngy+qA/1kUWIQSRq2InJUY8VQNHSp/1lw3z6yV+xdX86UivwWvxesTuNHQATQQlRwE+ycqkkPczoSJKnscfxsQpN+Y/ZqEmYKNuvIhA72kYcDegH9y/hgTUWsQtkAxfI5lw2Z4HIT5srdS+6RIG37jxK5DV51qO8/b8/YKkGosRc0MJ7T9YG6HhyzkRU/6z4SvvHrJdl4xP9Q9h5drmJNtOCveXO8GWIkEMIJDzOEk/De/fo+B91yr78e9Fq17q3KylRi4kTsvcOl1nE3F6T3eZF9Lo6BwroQw9fqsEjhnLVQfLWPO05g7RyZ8es3W4fi4QEjJQfTBo9VPoqkuc/HyLPu7ZMTCNe6MpTC3hv4dvBPhbmFbFVVTF4sPhbsfiXGGw6VJvh2xM0Sszol3nImstwidiuO0mnwvpgAwkfFvYsTHdumJqeK8/Egk+MFTj70BgmdLmcXaOrQTR+UTAYPCjHCDeCcd0Mm3BkWTqnWX3wM5LzYHiBAPVtvcb9vz+Wg9NdJJf5gED/0HOQ52rxZTqRrzynDPFuub1v5LtnCtIj0gLcfj4C/82CZJHwv32MybhCHX9IekZZFde2memp5milJmnreI8Nkue06WTLv7pOMru0YBFWgyrws3yoJ0cABNH6sBTSavq/kdn5icq3LxHQAl4OJLPyqjS3xz3YY/DUAu2dVFrkLtw8ZHnNJkUlvUu0Neyu1vYkKvBi7VjH3mp0/t+1J1vaBDh9m52jfAx69vs2IqHNdFImsromWk06GYqIGd0vWJVtP7AV4+qeL2eLl7tEtcvlXBD81ZekNhqoBt9w3Z3QP9wN1txJGk4h9PLZ5QGNCS+bXo9vRUm/NL47jJO2LUoqfBQSna2vC80i5TxwNWrxtmkCYSg0r4PEstmHrsXdfQohvSvw+16VZT+oogPjDwpm5PCaWY4t6aS4HH5G+cO2AJmZ5TwNeDVQkciULkCqReHvXfB3pTWisWydsqwc0+L37+akDqETYFA6sbJQGj7lWV7NfESuZu0VRSrY0tkuQr34eYqFjX19l7JPJV+5zU1pdd+wgaIxob6/Bo71F96ONHxvxyY6+lMs74tMpw8IzfkKpxOxzuGM8iRWPA+95rF45+k4Mi55qmxMBBNGj+PwAMjDXlQ4rE2hv0aTNNvrha4ohjake1B8b301zBBiI50xfhd6eIBhs1nPjpUji1jK5f7FFFzWBeejxpcp5vM11yB1GLI8pelYg2Vjg/RtXVISol4T0Ns+mozhClo3tykpQon5ruEITHRem3P3MdGujyj4PWLhRP18GK+DUcBQ6LKMqfqcW4T6x3r3v+3a/FcdZPOK1bHSaicRiSvB3fUZHEmvCB/0EyQvwuZl8f3zss0ky52lk2r/fNIJyjPqyarwEeI0TzeXGnFc1SQ5nuCOwWu6eZ9m5RuHu5kTRbrvYvGnA/gKGDHn/vDYo3F0yekQvjtce5367U6+Zk2/G588ej/dPiIO4NpNImm7bdqKeYZknyc0gatF6PDeSmzIL4JxvRLIZ/cio7RWoVVtApEkYPk4kK4rYXx6blE0yqHWqMhOQlzXHX9Bden2whLKhIoo9nUo3P4xsIxcp7WLebl/7KRa3wCJJ/bg1kzgI9602mAlncCZd8zTFM8DN3jwivLyR+w6H7SLS6zVWTIeFCfOk6E0cwz7bm/crRTfMEhJRzDeL2qXfPMJBI3gqZUq4zrp+v5rRmNKyopCM7645KDz+HgYxjx5n6u1kQ7YZ86IiVKGOTOzOZ+N0FkPQTQKz7Kn3dJdtiJr4fBBGa9+ZwhNtl6+ju8IWte+T9Gj7q8lQzfl3Pl9Ok7c9CAYubwrKCQbXTRIenBNPKnH127dsGipN2flDBV4nJGLZQN/4pxPKIR7KDTPYL5qaTb2hUnDNtL/1IWeOiWTtEIi8EiqGxT6KYPB7Z5TV1zHM17EhmMw9HpaPLHq5SbCwXHy2xvD8tIxO/DZD2131kHJZK4wjqfYvfXKskH1TJkFisp5r4GfTlAo9CzogbI5ZgJ1ff6BCbYYvQZPimwJoAPDideWRR1meBBXuwSs3S5hHeo8tXhSvXBh8mUPCP+MADPPjF+jVS5lz98rjCeOjqjTuGsGf+eKqBXB5tqzrOACnD6iJOAJgl2G4ZzLTzIY45zbCywBsFcq/lyIXTLOuGxTSyVjEGcB5APK9im80Os5X/hX47eqwPyh2+10Fr+mMW0DjL7dMpronzj9YEg7v4jtsAfe8JX56SzrlJUda9i1gLh+VcUTM08+ArPjJPYhvuf1BxB9gD1IbRQeGFfwg8KcXLy377PIJuC1YrSy2FUTyzeB8ju+y0or1qTOPOGn3tul3Xr9iFwEYEO19kv5DNbcJ1lzCjycC/fEy0CN9mrybLTgI0LCLgt+smGgVcOwWFC++8NOX/Ln65pbKCjojWM/TP000p3oX4s9upWTNkNw31T/zMCiU4bGFsoIH5iuSPwcrv9/1970uw1tb5opwSRMHVoqouXbnXyfpk3OJUHWggSgy29U3x2X13yzmMZsncQ0JsWKWcLqK1JkJVSS1uhZ4oKlmRRlAWbid2e0AHEd52uOJMN8514eNfONfSnSywmB7yF9W2ypgnjeSzwTavPq4L8L7JYGzAXG8yV5Ep57Eya7EIr9XyiGLkp0qH+OjcGwllBtNiIAjruv3Sgdds4mPOqOCLzdmAJWlCYKihFNa4Jx/ThrpLLElNEkM0gQOvt1knq13T+brjfl5dEnEcNk0wWOCTDKBdkSPA5Qt61srHYv8LAoOztWXW1PTbGndngTDUHfxQz/HcewG+bHyVxsw8cHZ19TcVupmgkv8a0OHka/TRJsoxWZy7WZ5ZjyVhNu3WvceVZbK5vq0xtbC6LTzShBF1NxkljIbp4J4E5P0WRZu5igT5QbDhtpp6K8yrfmmHG8t8CtPuQlQNwosG8pNbwCes8V4Sm/9aTA+wukhnJWWztgOe12ORPheHe35FziCPXIjZ43Uj1++OO7B8Yp1u0PtRYId6XH4Dp/sVrwsHiCmlxOLKwjUUL/OIAejemSSvbgipRHYZjTthpT7T7EjjR5A/N7ZIvqDMVvDjvlGRuaOXdUAhoKdsTOHbK5gqYLCPZajswyZOlxVqZR09h8ECoYi5tASf8zdEGPahl28nKEvXk4f+BD17EAz2XsaYk92eAvhFgrmYpJLCDEK9qjgiE0++AqDzqnwRPfPWpFW4rRy7i+erp9U5N4DCfgRddavqcUfm9MXpq3aPmf6bZiEFNzdd1r8ktmfL2dfc/ruqFM20vgIWBRt5zkOYX/0apCgUI8cwkX22gLPqy+ojD1CF07fZxIX7k/5uyKiFuKvZU7HUAcOJ27Lu18d7Z2U6vDQClg/nueTYG23FG9pmnYp+OD1GF8/bqLR3hDzN0JY9kfPhlfvUauebbCupm5wQkjki8gVAc80vCf7eePG2w0hCkHLZsuy8MAutT95i7uHSzKPUGw8Ex4tyzjLeLjKODslcW4aBZHT5ibgnOfcqx5G6c+XCPA/vD36nhjy+++qmeO8NtvwgGOxGq99E31BfW+dZMupA6TciQtJUBsvD8f4jlK9N86Ciu30jvQMmij2wFS/QMLe2I8eeP4DS+s0dNGYjW/06Tqm5nzqel4Ee7m2yauPqwR3gxN1PzRCHUO5upCwMwBFSnr6gz/f4/I4v7NefsZlYHS2+lhtLu3U9/WasUkr6y4AllyKIelD8Hl/N7U6fSR2Q82htVdclLjne0slLL6SgS0y1zu40dUgwF9QK1j8fhDgVAri0bnz9pYldDhiEFBoWY9bEREe+ssEeMZlROtu+tvFpm/5h1kkgzcc5/PFcqMsnvaD0slrItLzfLwA1qHNvV0xF+qvmkrcYHWhihjtxTD7iLt9Hty9wHQG9cfPRJmy4PSp7HWfAvgTfGINHFeO4uh64eCAKyQU8mQGTzCeyVwX3gD8536idd1GL4Zl7zwGJ2R/gtfvaWk389O7RxbPd0L4FEqasX/ppCdO71+UrUZVJD82yzysgyYeD9XovA+0J+hK6ZRLxKrppnUtCzEw8Uo5mQgpTxA3NSrOoPoyKPoMoLHrKjGTQCGKpscJRjUWYOE+uBdHxjWhUEQ293q97SkwxzCE5/x+T5qXmC5fK1wOuGGDtz4+UzzC4K1UtlOaWiiVUUTnxPPLI45KQSYZVCss4aK3W1jko96B2K/8sRM+aS0NmnW+XXviz/9Xf67nUtI99rzPzIkxQhxt5teurc4LrLbJnhwL7zmHchRKlV3bWIX4QXAjz+OzZdBQyjEGDqKAmEtXV1OWpEPStfItauDrN4oAd16W2kj3zou69tFqUBCb/LubOkKVAmelj0OLknecfSwf5aUs7rVe80Sak0W5iO4VMjbUGZ8c3KbFIZs6ELfFgc6GYKqYnD1+uw8GVMqH+uQIfwyxsyaYGu7dZjGKZDxWZfYszDmHpz4f5LsYQtE/Ex+cA8UrKs76NrBz4Mo7BnfC+Jbh1X4RtwICZYX2SNyaMxs8ZACLUKxghR7wwbkK3L1B2Aghklf9xE0Ah/KdKO/kQ8ycuwI7BkHWqQjwBB7PI6nZgu5tBgb6jb6VJXWhTF7nBFdqRdhcwWBvHU++904qgedj2Xmhx8WkVGiXbPN5EQmF0o8YpgkEqSjCjr3WI7Qy4N95h79460K3PHsW5k2EzxGcVQDmD5MAzuudjihFG88YVq/z836euG9w4e11rRuFgTxsgzyTvPBJ7OVbSwCtOnibmSDPVmmaCNf4I+JINz7V2LYdlfuczyz1yEjzUbvLx8KT+PdZY7ArKoLpY5b1Igz1vCDNrHsrAJAsPhPo5QBjO1eFI2Ukw2nOewbREpeVyDTZn11142RjMzfk6qbY510D+FKrn1piWpG6FOJ2GM6QFCCSpqu+m0bmu+eYRVNNIWbeVhWJaaeG62mm3ir09roqENL6wiRn29pbTT3pcDv0Qns9O65Hd4gEjz9VEAuJeAP+ip3vbf63fT6XAoM7u9AiRanDFq+iD5ew3/mex480oWD7H4/Y0fhpx0Ne/6UWUu0a9tt+4P1QZMyhvpqu5IuETkOWr7LWs59txrJXhxbywegVY0aJb3f7g4gKqZUFhedq8FFXWV+mqaAMWR9YwkMThIg9/QH1lHOsVpXSZz4B5DmKTeWDFc+RfeLOvRCcVJWDFsTKUK3GeyXcSIHb0psi6J7iF0//JUx1KAtIDXV6T8Tcy1MrxU0K9mTyRRELmeWhjqtNsAWeco/ysmL17PgHCYumPM4/GfoaV9rQOOyfOG/uyGQrjqKbAQNa9i1LeLHunGgl93zi6IIB+/FplEpfHMrXxGYVNufUEfQlqXGt27AUMhP5gm181ZsO7+umyig8Acf8P38V7S2b9mmD5BrWPu2PyDVWo3VmMRl2zoYbRu9Gz69L5vvMb9cY7xgjSTDkA77JYGxd4vkZYIgGKJvnaJ+fEtr268MicBADo0v1/6Yxb75l1H9m2dvEuqL9MyqeCZmok4bLtYkPEjOO2accO3nVnWSRcwJB56osAN2mAO7GGQhgAPGixq3BmQD7moHZy7YL3kQTExHFfOi/6kIG95MrRHoCVoWiS1jjrlXthj7MGbB8fbwhHum09ZmEsoh/vzq/BtH91rqHqvDie1Q1YDLvjma9WQ2G84Z/XRNyd30UiJR/+L4BmShbdWhj4IRAvrW7HbyfO33LbZVa3nm7T1Y13gCghQrx+/bhrl3YJwMXEfP2n211Vp8BK5IBUUrGHyZUHi8Qq9bSMTwKiVdGUjvM8m5SZ29NvSvFYVxbZhN4bmFVWJ9XpMgxSQILEO+O7iIMe48eslQ3RCLdCH7k3xZRyFAxBWdp7piTlJf6CTf66uBUtHlaJfKLStY0HUidG2/AuNqQfqLvXld2wQf+eqAm/O3iCtxuniYcwkYij7yjocwGWKJ7jx8srGCvUsXhkWwEwHt2vuscAdQU31G//56dsVu/953cl7Ek2dHh/n1+c1m9f/R1Bm+L/4yw6OkubZb6sA/h2tUtfU69vsbSw2QABgARfgKaHYqbxZZPTFKpp6jYnELcpgE2V9DXQFD2pFUFzgvJnhtNbdoN5r5A+AWYG8sbL58vHB7eNhCFttQlfiD8ufHCwcdux/mKLWVW8BoUUjx7BdnEoXpuModYaW72nxnXb0tiHLcKOneP8rfBOD/VD1Mm6/h4ECwCZ2Ddafyv08bT0u9vEt4jFIv3NM/l+9yEv3c0X80bUCvxI28/5tHrAJqK9wJ3Ac685+a5BADOo1QHCMfQ8YnsvPElO589og7IqXa3eFDsLM01j9oBsplp/VsH7v55v+BUQIdGVf2b9wIlafx8az642Jl0yqtzldpjHvSktmK2uA5Z11HYRaSN1WXQ6ISZ7HiTLApTh9kqk2+J2ZXfoyvdkxfqBb8L3h8JN8fBm1XThYpZEwlI/nRVBiJUicniYy1kdD3EPIk6+yv92SsE/ZBvASYXMjoz4NQxGpr5UdEmU631Gy1Rdd4Qe6y7jwCxS3eSqWbGRVVw4GfUIvtVH0W2UwAMkrTKod5+J6zqDa7lu1bLueFzBJgIbD+EeTY+BU87qhss/+fdaOH7zRyI0MTUm9pSI13YDM+wcPKfb136WmnteSjmjV5z9nMof7erEimWnxjKmHVzAIzDFp14l5DbtXskn8+JzpP22Wt3S5j8rMAX++NPGiB4wTsyVff5v7yeMeZTXdsyMgHX+dPwYdbRt9+RXA3rcvjBg+0+6Dk4SAV4FPns6eQDTZ5HK/3HWRp1eraRUl7VB95+T7pIqjrxCbtosxQ7Yfctlql1D7ylHNc3PV++S4aNUw9paiAWGC2aPoCDb7nHYUxzWBYpWfwh+GLHIMtBms/cP069UPym5lljtEjrHZ1sBJ5+9pZ6fO1myLOZJ8koB0S/vdHG7rXwCCEdjco+zfudSAIfuUs0y8RWRqLIcF9lvGafZPRUhLP89DI0wPQtfUTPAZYTeLeSI6hSuV44wGmRcNG4qd9p7QYrTh7iTdEM/snhqP1yjH25RtS6n0dmotiV5zNVRKgQZ1N3QhGvHtDTXvmzlTxAYNORwwzv20ZE+jgASk5WfqPVV3tNz6HU4wx719iopooCDhnfQxA2hFaO0ZtHdue+V4kDOF5Pui3cMmCSpu2+IhmhxvGZhc2/deWqMeB+VTfGiTjzHpEGPn7EJ3hKLX3IrC+NAGukMr/tb9ohaMKLjyDScbMhTmEsCqMerRP56n9/P4vnxlXAkQccD+WK5eNwcxtx79eTStLRrB6pPKVYB7OpYW6q5zTntwdNNlgr7RhbwHpp9uz0+1xiMQwlq6DqIxGbpAYIZYtUD5HB5SVhm/tv3xTwkSsycoBFqcX3V4MgRi9u/xJ6gkV4U1svi+EqIgUIJEB9BBaZfOcVm5jIPrjfwWk5RP1Af2cG06UCgIhhQPEZecsKsAAZbxnw/C5tHyaQjG/tUuIzs+EWWyabVQzVNkPvF9nh9WuvqYHHcAjI2cL6MWi1Wi/73paaJ4XlofufaaBlFcKNIcseQsKZ164Fo+syZQAdljWVNQFWb6iwALSAaM4VdqcEHRLTfpzA2yVhdRr3kT1mGRB7BqbyiPsCbowvCuHlzEow5VflAFakt4kL9DQmp7nqeQR3Vjgh/V4YF3vnJ1sDUPC3P/YeOVgFsKP6w4dmcaTc8PNu6WvgnL5QAKunOrkybq1c3HTqVn8OqFia8C6yGYv2GZ85ANf/z5+acUr+XrpZH3FBoewWFfBI/ob4xX6wfPHtzqZIYguZivc//HfFSUxt5wk5VnJHaGhj2JStAk+Hup76ACV+ar+OPK5csqcdPODaZWVLdTKa4o5q7g8K2Koz4LQSuSUrs9+8EMP+Gmbe9mlLHXn0SGaXXV5nJDc1JrtsZO1wIAlFAcj6+XedXkA5Mvb7PGjqSnT7/KWy8LKMIhRU8Z3MzQ3gM8GzhLXuMAYj81szGBcDUYjr7T4oc/vqIsLVEeBJarpbNeT4b/hmT3PSh4oW89kPX4KrJa8YvtGjyftor3wke7TZmEe6j3luFMUsQG2uTdJntNXuc4M8OXgiVB1B7SU7POiSWJqNzAju3/libsFvEbXvU51rdkakCKA5EBXwa0S5sy99dp0kXUI5PeXXyHTdQxMu+VsROh6zCy0YX5bM+R1+dfzRrg1fKkZh7IcOaxvNcrijPXbyqjQDZwOBpbwRcPQ/D6JXICZC30vWOXu7Duyq6dbNp7fl75xYMCrwWZ8Cp1yZ4b5x4bQ1YyghCAjOPzVUQ2fXSAVjmwa+SUk7cqq5CBwcygTwPL/nNoNQiEvNpPfg9E9/dU2U8qTd4O8xccwGGOYp1E/1RtQzWleyxd68oJWQReXVeqfjVVIaNquqdcfdNYSXn9/Cz+vRpxHxRZb70WS1vT0NVk/bhuH34i4pZNVEPYIBpOoeKHv2CThTlQIRcnk/0gF6p7d17czDLmIE/nBtcKaCV1hU23EbxTUjCoIcDY5IEAgG9QeOE3cAW4g7fBmXsgER7lLQ5NfmJYCeCaNE8OqEHvmB5/nsdMoujzeAkfEJe3rOpIEH7m2z2lMIG7tX5dqWau7GRLKEjehQLosPQwSr2VUSAQyxtGOI9v+eQlN9QIgAV0117N4yy196DEweXIFM8gHHb5j1CTCPFhLMI+eRJPaxuTvkGdBcgHAAj8ao8R2TOv2AHgYl302EUs6HypTPOIQAa4yP6Yv7OC2OM+M3FT6rOZl6D4T6KFzl9bAQuGLJPOe6SeTfqXSe7IE8JN1Lt39qYmYc+b9qYh4biJ4ULR/1b5L7E7ULTqyJN62LvU/d7PA+WvFAcu9UBId5Qyk/IYfe+tPJ9+mS8W1tBoimtn2FBsLo8SDCecRoVTyIF+f1v9ATtYw6huoHzPfiAaeb/uunz+JDO63/YoyA1jT12yVGGjIM/5N6Q7hWH2nf5udfUcT9/MMZy2s0v9QvAvjZgkfMvHxodaWk3Fs1MN4H1GV2xm8zb/qdYZaVwY17Nxq3mY3uwVOEoIYuImJ7lHH+StUlLMc/kxm+PlFnzxKDHNq9O1jGzz7EbbhdsVOWv5OsAwwPYptQCqtQ5pGoplCZfQE+40j1A7AxOBBAm36K2fARudIZYmjz++gxTKNUHRldtQsqJ19DUd7GGvEwTkzJP3Hi7iCEbqJH3TwMYrpBpg3oCHj1r/AxcOQoS1WqPiQG2j91xgKgx+Bn0y/5TlazlGDun/E4s6Du5hvFIs/4pH4TRXzpWapTjuR3vabWLhHjDhT2eMYWQEtxnvVR57BBXeSbGvd3fmnDFKOE4P2gWqiAtc4RBUsFJw5YETonop1mPTk4METwXmeUaOV5VCmjlNUB9BVgzsnmAOjCET3oebpm0INbfXtQjoeY4GHEnxx6gw3/FvnLHFS81xdJn6BywIZDmSfLBK4zBugsRD/67aiGqnh8DzYAJ+QUe11GrmULcb9DVnTg1ibdFo+NJc/yOkrHMXnMPyCWvU9gq4v/Ul0YleLROdLm542Cv7zRDB2j//NGt6J9ZI5057yQ1MY73qjEjf2gzO+8s9AkxAQVTt5Q+QdArYBxj2oLhdbAf9jVHRcetwSwRJmGOVPccKDFHlpTb7Xw3OGEOCGLjFtc7IPipd1t2s3xqmpNKOCwOr0nBVgrd3uC5+R7ai7gMay8wyjCoNIIUMTkNWSEGk3OP5V9unZ7tiiw4P8gq+bRn8LSeF80wSH0fxbSnzxd3565MU5MoDonQISdgZI6P+YMjDP3JbbTSXv7KzaGHFspClok8kt6IoJiEeQvXkDx4yfMw/jsA74zWXNDs+szQmO/fwWMYL+y2fqO7LlTYf1AADTyYQa0fBbHtVZDX0WmqEx1/KqxkiYlTpr7+Ma7AwOseUWg36LlTfuRQnd/gi90jkpiJg2dW6M9OOCHD+1xRumv788TWt4o3C7QqtrmxYwN6QhVCBn4bhcv1LMyU8As/tZg4Cs/ybyplb9UmDVIYcEKNa1ZupPpT4WZiEuF0ZT9CVWYnWLW91X/boJY5fFFAujT/7l2Zq6StV+fGoY/j1JPJVq2zq1bYXf5ilOYGfr/me6TjgHUcMYqlXIHCwBU+DI0RAxtT8br4GVnnrUihrKPMirK6lvA2WG6rjKGc8rsL7acA5rf29d9UluRsJ4hRtNDkNdTUEqw+7F8oZnogDDJ24TmfcK7GpXGeYZC8+cg2v3kuLomudOr4rorfRxhOo/PTzkkmuQSQlbPUm5rAl7wd3haqiUFc0KuUL99Il0+vawYuDQSsGzilqMdxNMhV7uPArwh0n8gYf17Qw4XAeLqIsWdU5BkKBJKRSXR9yTLSU/A3+ZwTlJm5QHPKxDgOTbM6GuuacO8/zv6jDgOfW82qnibZMEv+sC9QzD6CIkNow+TrYCUmNtf0UeGxa8g+hBXdhBmjf4dgXZASkAEKv8TgTAYgQw0jkyndXcQf8Rh/cUfQElaqt48ab0oSXnC+KEl2ADQ7ON8Al7u1ucidh2BiFHhO3h71jkRepqb3O3GC/Z25vt65Gs5VJbO0ivh6itfLOqdf3pyTLMkgwNk+Bilk2vWjEc+A6g9XyPHHwTvusZ39/3cdprt1zc2F/rX5ugqMI0a8PHr+bdITYCYlNMH49U/tvgMG87pCkr4PARr8DTDetl+JsPs+6tidPzdMvQS0AmN0d/SzMVQsGE4GWY9ssr6BHHuuFXiOvDXBkuaailLLrQbqi7NjaX52/RRC/n50WZFSkwnOBmyhgo32cCDT+0wssKqTXRmk9bBNiqmJrvIxWF+HINyTxK3Wl70LYMcnKb20LNQ7Wlh4InWHjTD19HLhPHmlhWyzdwT8Pf4YKt6R52DFOcqm5IjrmvwlpQ6TytmfuEaOyS6TQ23Pu1NggK+UfHyudKG1xG2PBKsCVeudLCXi/3/zVwG5g6wtcHaIzA8XyHG0aSIQQRmq1HOiaxFNfCK74lzi5vT1hmR7y1bfDtXYuxuRuASUrG+s7asK45WfH6bFXxFXbaGdBIFWIGQSlRr/ec/5+amYf1L9oL7EA6hv+GYtxrU58Y2V5t/cO02hodBdeockV/K/oAz7RK9LAHLgnON7gRSFenIL4+zeLoqeu15WEvBbwWYCWTYSZjfq7m7BaU59Wg4LQlY5199bO/7E4bWu4tA9jECmKMA7Bz+wc57ArGzWvdlkrAF3O8e1Q8CqjRkNi9+bej/RgTfB5kMCbPPjZ/Np0uiXKQ5ZTwGM5xPK7gwSPbMiUn8Q0jn9SQwLZ7v98QrudfrQaLFSmo75Coqgf5/cxW/9rKyRVPO9rlaOvyh9lXDo7dhqLH6ITgvXxCKsC43RShcheR7sq1bL0GzwABcYR9Xy7IpYb5NfMY+hhLAFzr/kvDUdC5UqqnF7WfjuGpDft0bw7UnKepudDpVyr8YCskNnYba41/8ZA5hv2BygvD1mwh+S0XFEv9HzJckb37EE4Zus45QodJfWfHS8snunQGSorLaUaVRICCdO3z25pT/owbfuY4DPjQKO2vGnuQfPggr2jTtmfRBlAeteD4UA7ClUb2VsyMsmO8n2dD2qH/OeVJ75pxlZJOCx+kCd+DO5iTUNKehxA0vrnkhTmhafNcqBIcobeAhxiSWg8I00TYL451qXxMAc/Ukjd4wkTddAG9cunjHha18WGmDr+reJNOFkn81ZhU8FbpeBwSx6z73jyJ4O8jFKcldSF4GCqIvdLXGAdiGAR8kxFwx7tpf8x9E24aRY2trEcsB2g9ID2PyvFFh/wEWqvf+6Mlpwlb/WO29T00fHe0Wgsjhqrd+DGkkJeMLTl/16NJkk79wbAl8PJPMfggVL35I1jf+jSUQj2+vE3I9bWO/kN8HbHiJB7J19yPdM2ldCzUBP/1jkZ1CYVJlMMvaz2DSG/9cNRvoJ2FjqFs8D7uYPJxqT8fgqnL41ZJAfMNNUGffA3V3/3p2UGmVKoV70D8oBj8K/QGz8/K/Ep4h/E7QRGaZqR/yzMK/kMrb4v7P7LkxbulP3msuzxmR+NoyvRDgkr4HDJZHVb1BpIMeIzhHd6O4Dk+gzBXq04Ugjy4Vn/PzoHjpGNy2fJ/333zz7AzrANUUD86yadwstc/Ole40uGoJtZ/vFb7Bu47C9i/MqiWYtZDa5jRTlcl6ahhLAD6qMHXOKvjvLI0m7xHgixrwQU1zZn91hLUKZFRJo8s5VQoi1Kdj4OH5bmYxNzPeXix2KLnDmYI8CSPMvSnwg+K+AHMJD5Y5Z3PTyo6QU4RCTtjP+atBR2+DmMeXQo63u0RX10aGJEWcrwyrJQCrTm8t8OHfwDAP8w/Lvqxd5toJyhwfY6B4axMlqNn3OWs+Y2cjIlmEYixyDPLaEOHfmh8LCwfebQQln0gKpIi/9Z8TkdnjpaJ76dpqW/WKhPsPJpQNFPOG046d733mDoCW5NadqD98ge+pr/x+HC2UqkTA39r1KgpNbWMaAVHuUdPwkn+2rI+uFT6E//vZMvQgf/PKV08Gxeh+RHArLxc2W7aXseaxrD6Pp549htgy7mozPhDqSokuuaGoCsY8KUhPxeD7gVV9y1W3Ootn/m1dZKwQeEF69p37ZU5hvo3dMF0GwfZ2xMF2vSfVC2A/BNoJs9sWXTctwG09sodnYR+D68675teLlAv6D+Pw3ZWNEoOPcg6P+l+np4Jftzx1n96R+o++58xkipsoyghTaKAAD0Kxrh8rH3x3RdoLQlII8OUsxAt43ex2l388HWYlxh6bHYblrinNsnPXAuht1pYKWd4M5fGaOx3ZBqx7oNIRFqeYTpnXwBLZ6O5l3fcV3iSLXFbleKO80AEumbfptcwbcRtvXh0Hf/81hyrR1qSPCEIyVkZJwXc6KmU6Z3P/TWkXBzyvAQ1IxEbsUjWUAA1oAQ3A2/tpjM7cbkG/3pILJXyoxQvOG/Qgbh9yk1CyAvgDulNxxwcF8oY3/E98/+UY/MjggE1FsOQJRveg+st3yz6UzWNpznqLrQTuH+Qu1ndEVv37X9kcZ2HucPZnu+wsfNO3+mZMMEadZ9d7RnEAs34DvCqPNjs7yI37cgTG6UPjBllWXgqxApDSe05y4sRSujdb1Lx76Tu4tBRzjNiP8/3X0z2up8suiwXLDu9mf0XEE0ZEmFoYsjqQPif6Hf4Qqpa9tIcrsXDvEwLuQfiIzIDLqP8wStqGKVUfJ5n3I8oS9bp7zHbFcsmBtxad5jawElaSUBB5VL/49vlkUGP4kFk00j9/fVmp8nxtnD7OGETGeRsu/0Xcz2l77E9uFx1Etg9uLYJfr9W1ShoqgRaA9spLFCKE+mUU1IF+GsnMu8maDwBc+zRLY9m1QAfCbqhSs7TGnLKyDKkoTM8vJkpV5dX2CyaV+X/eBJpIAPfB3d4vzxF6R2rK2wi8KdaQrKsCQFFitnf3TK28ZoycyuAH4oMA8M53jtr5l0aNVeYMg5HT7or3itq+Df+pfs/tFV+dxz1tM46eh3z/RS0iAOz+8owfxMZEKiXYGWJJvkrp2vtegisi/kSBPslSEGvM9SlSCxliGgk978ucqheMg05R+KZQ+hvHcQUHzO3lvVS+JEL96+y0zDDu3TYu/1Bql2+AWoN9lVJ8f7ke8A8d3bib1vY2GjU/z/wG+Mu86oscVBG4VS4qDfIDrf52RznfC+BHdFSCnMHofwPqobhX4N9DtWD1RNxGHLkuWvmNWWU+yLZXHnp4nf3W8seU/xiKDbss0aEIWfXSqChE7IdGBbF0qNs2t7cnfPbgIsDTLwH/xYb22rpQtP+tELgXxUMvhwGD5Zx8UGjDhZTw3Pyun/zoVlhMpB0cf+X/saxoIM9Ev+pVFwuYy3fdYC2sjBctuKOV1fcD4l6PjpEpmQs/UoknwOZFp7qKFdmRtQeMaGk9309U8MXv08VG3TshgPtTD6bbfxU2P79ypY1FO4KcRNJQ8JW/p4pEg9peWY+Gs748Bgg1q5PGC2DAQ8dx2qWExPqntsKlxWdswRl1VDabfs0df/lkE/rkgY07erz0GGgYt16WrGMeewrT4/8qMu7zT1UNLH7JbajJSF/8p8nQUdxsTxVqZI+9u5VNe849wsBCpjCpZ2dv4A5b4MM80QxY1K/Y1zvrJ/ZksElt8CM9hPsqr3ckj/gBvFWJGuXWR9XuwdyKW9DbG5ep1oSw5arpfuCk5f7HW3QdtE8n2Mdz1P7EtDd33gB3BGZ7J6To7nKMAfwuCssy7uHNxL+vl7EX0DsaXSDA80QaFO19M/5/IQevTlesH2y+YCLAohA7lhMDFtzOyG2CXnTQ455W56mk43J8gtsC/sJDUwPrP84/1V4dowjli8Dq96zl5DmE4LQbuA97XfaDfZcuzhPSZ+OGdN2nm33cpuKvPsMzysxxZXQYlAHNiDmp3lLRJvuXyLLTKMJ6/ohIswnFrd422fIIXuBJdKIMmPs31lfSivq3wz9a1f9T2d8psEabCm7k7Z8TXVd8fCu4JAjUk2CbtSVxVV/ITjz3q5tXKHRR5Hs6Eb6z1qfP9/LTeWkAlK2g0253zlBkp4JzqLNX0xUEeyB1tLHqXYeDXV65QUF9k3OzNcsF4a3h50Bqtze3D8rdS6pp1/5XfVdFHYLte8+CoMP76zrzJjVHylxl9rD0SjS9sa8cGL1p21t6qwZ8t5vwP3kTtz+X5wtl7TqxoyYqbgDAgd8DwHGVXQPdnuXKeh/+6xVwCwHFbVDZp58mLMe8kMEazvo1y4KIZdmNXLgn4/xctezxYcGRn+xT4F+zlH/oyY4pHPoP9fFY7Ts85wj/gLXE8xGuSIjCZy2eiik2DcY2cCaHt2oH/dnaB3FngV/s787raUjRpivtt/Z36ZfvAJ6I1OZBI0PVgiVy51t40NmKkXi6DnZtNY4VvxL2cybV8UK+k1G+Qsy1ESLOwvWaUP9u8vuzx2AgUwmpiFObroOfVms0zzCDMSyZUPOBXfTjX54UcJXkRzr+nWt96gDjaF0cqj35txpIfuHO07t8uLQxL2d0TYcKCHo2rOKKCKbzDBp9keYLV+hOGc8sF4PghjzBGThUNJAlPP8ORgS4P+SzM7Dd8NbYmfD9HsTzHXq7V+y1yH+2lD+J+VYdCfqnInspaugTVtspiT88pBQEHkEIenxrVFPELAg9KHqXow6A96nFabbzXBwvy9ISVxt2ZlFI8cHtfLHReA4+WPpe252kwn3B1WLH6Ht57YAjyvW78MBHLF9H6wrnu9ngVAEriegbjGxK2yrWr+K0jVIVfzN/lFyYWLeL/1WzIyBHPzrn+PpPzc47zo3BfPZwBQksP9mfFvjq+H42cwme8H9mBtkGxAT5e4DdScLw7/4jT4mXKE72OSBdNmF0Ovq84XvOrWuj57lDMez2fGnItJGmkfrHeZYPImvK87scUbNH1Qel6Y2y7vhUItVroe5iXb+Qp1jOrABe4ENr+G1+ePUG4s8D90kNQxRScDn1GgzM5WK33ORyu/uV7pNZLmO04vJRwi4YDiEccbhsC5iH8mZYcoa7K7JXlZiqomxpycbgeqbRMPM8mRR03z8tjN3/lAq3IhNeeUzffF9bCtmLk9Jny8CK9PfLkmV42hkmg9uQ7sstn3p39Ej4MeZeEqlZUKKQgJhvikey7Ngh/RN3g9iNYfC7D/yf8tSOGPZcWuHmk7U40m19Vf8TuWetN1HvxH8ZEntjOGyw7LTHTHKZGlh9jrxU56By4M7eAYNa6AxTLWkw9vNG/rsi67ZbXaK+emy4tjw8Bw3WEKAncvW2TeuHapPkGlLZ0EZbf4h9ogQHoBrXbp5QEgo/UCe8vjv31W2hYIVF4lX3rcoA4t/nx4sjFO5FCcVnSlSLJf13q+5JPAusX5/kIDiWxH0GfHwrq+1rO0HtGfYpeGMwULvjDsurvZzMsVFW1eRlqvObMHDqMOEZYT+S4aDVhXq053bjr9nEkH6oahCBmPIhz1OUeU3/HpE7j7BPHRM64dOr4AleG2gzVeVpQzfY7l726MYMCcWPVht8rh0a2incc4g5ZSudIbFfRcAF7pbYTAR4CVOhbneUqu8GVgxG264o1Zz4OudnSdJXtfSKmT0zBZL+As7uWzVW879137/xfyf+G/9n///wPwJI6GIP5WmcemG7/F+ZFP6a4yyfOQOoz9whc3ukL1WrIPZ/EgQ2MI7fsmsYlS6RdRY/8OQdMeDyX56BBbvuS3y5MQPrAuCXCv1RdvgPGU2Yx8no8yWnHo/tKjJ6eTL7dpvgCHcLe1jybp3TK3lFyWFCQH5GqEJfg/8at1YY6v4Cl3HDa8cp8iuqcZ0Cd0Rm7+9d2BOJ26TyJcDGUXjmmGUxBGG6qe21cVbHMUeWgzRhp9/ewft4K7+EJovYG+ZKtRA3Rt2KzVhZgxVPl2eSMASJ+TCLyJDtVLyS7DDpHYq7zNXP8U9/Ko7nSeJmBP2nnZqGPvZaq3HxjPQ9LVmWweeTf3JYp8kQXHbJxHhnkdi1XlPuOgB7IpIkoylErR38LfIJPa7rOhCEc4IobcZWiW+VqbGWtSOeV7/TVa7Pr2m+r+Hf2I+rfWSTHOZgRCAv4tgkm6Z5hnePs8y1b2AY9jTLJBkj+3n9NEl97h+DfOS5LCchINBFNRvBqJ1LQGe0lwvdlso8jdN+SBX7JyU2OGM/1d/jRsEcFXx8sAQ72u7Kh7oCGF7htJ7B/zkdn3A1T++qjLMT4MpK31YBG48E+uqGIX/dMKHzdWE3jOKpTaVh9z9a+wDOb/RAanhEvgCL/lu9+cYW75nGR99NaoXd4N9n61vdTRA7pBmLZ904zR+e0NZwNpyT/2lohIDgVf3NZ2sS7kt7jxxEWDT2HIGXYn9r1ZFnEZJHC3wi27tQoGDnp2/6m6pXnLjJOQIBgl4HslWY/MKXCc3d9l7i9vV99Txut0erZ2Q6jCVBlKkHYmHxgnrm+PLY9Yd7uarw6ILi53Xl75zDnXGmeZYYHmk69z2W3mV5RzS8McB7jp1HxXXJixtUGtjf37PUoAVBAaJC6GE323A/Cb/83K7RuGqPDEGkQhMhD4bYOZ038/XptPDFKdsp22SZB1yk2UFbfj6BoBGb0H33ZBVX8LtC/i5EE/Z+jcFNyi2AWlKTkyJuHIdrbTg8HyEr5gbvjfC/9503TvugyF41D2pyg4A8WGbqRs4Za/Mm9vAsNHASXL1+DOhuenX71bDiNUGbc/03Z+K7DFmoljo0Xn8pxY5+AJPU9udgwqmKWV9Re9D9pVelv6iHtZUCHG4wjt7HXKNWQHCnL5p2fAAM4ujuk83eIQuA8Pm0UMBMnWyZz9vCBOz4V6ffLUDQ9w1m077RULTnARXAjP6jAAbjM8B63MidWKafZPlOq47glve7bJAjieQbNjvPGHjJT9P63sbqYnNP24S8BCK2HewDupSJYh9YDouRYfh4bZc2pwym1vIXR3pMwofle+NGna/G1GqYSLj7mL89YhSDh9jReVRtV9HW0H0gbdhvNfRZg1yVUEZYP2QRWU3TEEiu6gR7TDfFC+c/U/sMLRdP1f4OWsKxYTWJj/ZJkIeoA7BkiIr2Cr6c8jmCm1xQSvbzl4Y3vRtikfmYyZJt3zFbFj/Ic0dLtKId5Yu+0VqQjtxsj5uRURRFqEkazz6VV9MJvJioXRvNmqZhHvsWLz/TSdDVaG/Lmjv27ZFGQWutMDEmynpOhpha3U0mUI52Y7PPyjmx0BELOGz8E7ajuF+31aTayraOAi/KrtyjKEft3Y5v5Bp7zYzkN+WTh0aiVkfiwamU1S2HuOUc54dsHSxw27r8XoWseVGR7aALE/1HvRhi584Sqn5OS48yZ0/CkyZ+lJf1Umb3GPATCc40kufKaAH6DTL070zJSifdxKIg2i4RBmLzUKwjfslA1FhjZj30HcnAvPGawVBhmx703914EjjtkkNLrzPq4ZDzYaNtAcYCvZbObJPBDRPMwDY0OKqz/+2l5zuN+sK3VDxvvfeXpSeeJ+sT184BWZwnxNfvpphyiLJARAGRN8bxAP3u1QjO49VbOgn+d5o7as8fuLpwuD2KT/vVEL1GW5+nC1C94l7tyQII6WiuUb+u6idyz6/RXYCJKpx0AYExQT/0C5ZzXF12+IlRw7J+5kKwCOZnS2vZUd+DZrSHWqAyPwz2DTqCs1ax2/1s8PfcuHB9InCgKezxw1Of1/07V6U4PR/9a0cJhuzHcakuyOlYFmLGryfydE4axM0S+/UKGvv+g3k8hm8nDqgWk3ufLwuCLM0tjpKw+7ZtyEf8LrrcBq7rdl9abhZ+ZxlNX/YQyVu8UOSfqcJ1bPdvkBCUBv/NL7HpSo2oOuyEyYM53yE0hpOAdl3Ftz+dO8/9SN8qJyVOysEZc9fEvvVMEKxVtvrj1ayFozql3uEOH4uYW8mnR2GyaXZk2E/3anKsHQC/CiRlZyS/pGT/wRIfD1t65DKZb3G6XYPxERY0JAVQE3QPpfd4AUZs67CyLfeEFhwMVgMeE6MiFzpAEvA4BbK7yGga7TgHhfEOibXJVOzPITZcdlIxdryqR14cidXc70l6aegwOJy0BUeJzNoJh+CAOP6Cvyht31E7aJvJxN/DtxLo6SxSLF4RDNtkkoAIujwOUxhg5e6WGdFSiNfU+Oab8BwvAeAqR4D4gO/epAlWzUS2xzWxPJW8udnU97xmNjsehBlvtWiImSZTmtXN8BogZ/iFJlo1SlLPl5Bf2sZcttMB/DAH543zNzUYkmlZjh35wmiF4+qkYzLwT05OX8MtmX0jCI62e8dKhHJ8PmP+QKglhfPI7iPM7PiC5L6c7yIn34dZjvRxfNcojrMUHaucCe8Z9jIMem5OePA2qCDEtmMsbQMvoMyAFSsutbhVQbxP2gIAcjR6a3ujZDkoYiRCuw/T/JmixP1gtkFUd27zyP5pDn1TI3zVtubRoz0sQhpJElq4McDo0lDE5B1HNe2hrg33oyreFDxwLIVHbX+wUePcI0vrPydzY21nJoZwD0vKSr5DHMzo1wT0csdraQfc/tFG+fwVYzbr86GNWs8FR+LqTt5SlDobT02ncvAHjsre6q/fykLcEQ7pn6MeXU2X3mn8QpsXQJw+1Oi/tnfgUidKZY4C0x+hS5Ebei09M+7fa2wA7ItDNUplJnJwGUHbDgM4LGWq4FErJRwqY11/db4SLkaBcLM0uZi4kWygecphxtIR4U8jq2D3Sw652DABuFnwomXAH09Ccwwb4tSyBVm2l2DHSu71bvCWBH9sFw5hyo/F0nZxdZ58Z4sp7Z9GadqvIjGdukYv/p6Z6fZn841dq3gAbkG24CxB5FYZd3mk6XfajuL4/A7ixT2KW4qGDrvVxuQenVCSJPRS+Vi2mJ/lbYzm60mWZHpr27b4ypyWmHA/hHl1yvCNzG2JEUlrMBbY+M3evJsE7wVP12s2GJaCn6obmzNaI320jD5LlWB5+cxKiWhlYSro06rUoRd9u29eJfIazTS7AgRUA2YncB/6hBZ+5G2QXmmJ/DXqdq/AHF9rNOZL8JH78cKMezlbYrZq8V2YDsdjp7Yx2bBI+3jp34zpnzTgpZv5rFuHTmgVHYimH7BjG+kZZoxbekYRCbPIobtn4M1eUh97LQOIs5EA2LdkadN3gWGlSka0rO9EdRF4MTaGrZCi9iNRH/eQaw8HNemvj0JfwPvzkAHPS6CPw4dBtZ1hO8xznpF9QcRExim2GzTnrZqGN1crQ9v7GYxnnTMkgohulyDj1OB4Gz1SAKIYeHLdI2nYr2zYB50RDpw/abuCdZ74+Oj8V8wQwyeAGBFvjUwNb5j9gtDqZXZKcctHsVCJvpXX5VrUyO5zzo1WBC/qcYbB+Xpn4o4TtSPdYA4mkysyLf3tV6HgAaxWDMZ073r+2n+J51ezxziO0J2gxPLdCOE7funnl4oAb5gbYErP2kbgVXK3RPlXU+2lPhUlQaZLU9dEVI7CA0725MYqyfPAwuAc6VRXSx7jhpOOPhsRba3OepJnpTo2kiaG3KxNvnPco78dRACL8O7c66QyaekGCUOTIBXN1Pw+O+INGODWafsr7U+z59B4fAPj0JxPXGZG08Ipy+INXmT83eVJ6ue4MT4JiWceIDBtec0wId7rh0W2dra/39vohEsZ79H7zc2+hT/Za/Xvqj7nYxofLNI87toSl5hF9B6RTTESB9WRG5Jq2PPqHPnVHEqxZZcmx0EdCO2Qp+UW7kE2Ool0sDm5pVFoMRFWLqvOXo1HHEwWJzzS0+Xom3vBJcyL7ypFsG+RIH2+N+lRoNz3rgFY9HjoX20vrOlFT1vDG4rOvVvOTvrqw+gagYW3XeEcgZC/YfhI34Kai4r1FTU7lDQQDiR840X9g7IC2qR5H9A1mw8N66ExU1+j0IPg7S2sR0Ev2kYom4+Iv36T0NpSt95QdaRheuPdTo01+efbJpXt4Kl8yhKYrDNCK6VR4nTGMZoE1mGpcN9GCUlIQbgm5u13VULgCldzQvKNcB77R27A2VuvvWvh6KyyQM4WCIK8/kDxE2KaTDjT4eTxTlYPEGdkotVHSnLdp9U0lE+4uh5Y6iZLSEAqR5hwXFEiL19w9/kehs5r0W8ERcXORIRJfrY0S6FsO6VKuzQNiAxZGcgyQnTTei7QtzLXrLxWxCnqqf8/rH3XlqtKlu3X3He8eQRhJWGFcG94BMIj3NffCDJz76oe3WdXn9M5hqr2UUokRCwz51wrIkKvEFFsPBhm+hxwD5mQmr0xGY9WUWQ5f4sYxoLrfUjMsT17wYsCqhCN/EwELL+/YRnZZGaWpCI6JGHLVDgdYYd6zq3bpgG4xG2SPzCzMuQCV+EZSjYI82Xju1srI9HVPi6nQq8syVV+5Y7coEii6e8aQY7IraI3i6J8LhO1aF14rKYouRmrAO6H0iSJ7xcvNOIcDcTMG9FVRF1s1bucU8VpsUFoycrZZ3KClF69VRnwZKRPdirtX/I4PsnmABQpqYKasi1zaeU5qaZNVPpSWV4MsAy0ucO4fZ+WB52FtB8NZgGITvUEQajLzeBGP5vDiQQCeyxZx0Tv5Ri9HZ6cDa1dgZpbJXi+4j7TbJrg4ny+v2gg3ZhQzm6GKgjKShHxgTL3PIltchvZsrpdhHfzBDgSAsC1LjIULR5cUT5bRNB4Cr/Azol4ywOcdlnf3w+bbgxyrLIuC1nLYTYtsKmEHEbT1z9mR6sYqSIBVzjiwzCXCXZ3aDizIXTa2vPV2j0mCvJ+GjFWhcfsmPnStE7fHoeepklNs4a4VSZU6SFbT0aifQb3h45qHZ19d+KxWVzTNL0fro8EVqRDS9/ZzDQBA4DGgnnSE0GmSJdps6qOL9yLekFwjgPLAn71MQxjp3OAw6GL7rLqIt5nWeLIhZGj01Zs3achwlyA7to0J+E+h+Ely3Pzdh69oCznwXswOMV7/xzhnmKTqQiVhX3IK+Dq4L/huOEULj0eh+RCV52/7oRqSuZmatLTT2ZdeS9M8Ck1HuSf7GP0cXKdZ1RTPgyZxHrjwpR0afDjo5tK8FrEgtWFJ/oMKr06dgIY28qG7Bs69W0EpgumEVtRzG0/lWu+35Fya5hei6P1SW6bPFTT4drG58p/8Ho0mw8hQ9nWQyj68U7y9lDK/AGrkfIrbW48aWSOQVK9fi+D2y1NDuSF1itEe6V9ihA6i3ZNxvTuMMJjfyVi7AmkHZ94c3ydVcDeyueln4eQaDIYCnAFwsANPtQ8YSaPiSCFjeiVCiinHHKhADEHTqF2+2Bj1XexxiOzO/guUfkjUkI5LBtCnxQR2dBimy76N8jgGfNg+3yDNVP5mcU9rOLISEZsCEqIQ60W+OPTrW2OJhYStYj5Uh+K9XryURPHzjLeY+Bb8wM5T4QJbObzeK4Ez/OXQFQV9ktwydsBZuQPDvAa6qnBE+4tyIaUmXXtnT5Wa4kfsMeFR92F0SbsxQiTPj5meYGK8BWe+AwtN7J0+Yjr62y1Lb7vAVmfKSKsY5jB2LRi3T3U31hApzTmtyN6OJ6hd7IcfD75PqCFkzXaK773TlWQ4zPm1G1bpM7HkUo3v49zOPfFe2FvxIPksgptUx8MtGGWkHiqXmwzUK6WKWid6aXOev4j72kBJ5ScJ7nGWyYPWJ6C7neRUGqSl5wF80m12hBvu/6K2rmmfKl+UAI28SmeqSq8YsU8guHQupo+4C6i4GG+H3km2yquPqmE2ysnWETIva7tgxpHIcKxQy6QVjFBVFSe9E1O2zqrH1rjP5hbHPh8Dz7Bmp+JIVVGa2Dw58lxuwmvPI1AQqaHTf5s0WxFlOmEcIuIqwnjpP8c9WsPCW/GeMFMM4oWIYsiQ3iymIsbdssF8Z91YgjpPW3ihf6IGmDtK1oHKTtWpbd4VX/tSS1GmvhNHHFfMz57xM3BbjqPBoaWYuEyvrEErlUQLpEdyP3aBI7ztEdDK7u9OmwQdgUmtCHiM2s6KJ7C85J4TDcueGWwn6EH9Hd0hxywG9mk70zexF1AWj3VzPlF3p9pNN2LJyHaw1I4LzwoC+4CfjiA46rrwd1BNjbIWSY8lVRySamR5ypHwX4/8xti9B8Z8R7Z6wtFbkzzCkpiiUTeiGTrcxOJMdoOFNG4T4OkHHtmj4tItTdHjiddFrcomVHb0auHPeYEHLrgqoHrzuMxMCtpJMguQu0gcmm2R4mSQLSNQAw8WpsOnzYEbjF191UG0ZlgYk1YCZJKGsab+UBfenFQeenoKtOYD6r202VUYGR5yGe/tYZhV5M0NBNX4JGLEv00SQLizp60KmHpZ3i1ibJhOJFF2FwqtQBiPXqGOt4yhOzMvm2+3u0Qbgr8oJBw0xwzQxi4XImH8flGoJim3NgJFvD4xEMiM8VIEK3ZEa1umD7CkGa0l7KVEE0iUURP7vN+XIO3i18sEUSWupCdh39/o9HHohr34bOE4rzLJh6U50Ap4RN7tvjmI9fiCSPLnkpeNamqCjvnXlqtXSZZzBwv2uEtNdX2Wo3FPJSn3kuCrNmvOXljHKW0Eza9hFzvNtWQhJutm1ySIJDDvjxioyuTmTZLABGPcoak11JxdaTuMwrOuZi+vX308C1VwkfAjMocjnsbvP3bZsBRumxvtcHXj3EPzeBNiwVe5oPaZaynY0RvjnXaEPS76NCxy5oQPZag38zEy8vc7jOQXZ/MI/+Y1NxVMTrkzaZlN/NAab2qlBSvdYb9jMDvkkTJRJ/yvfEFxrKd3+O+67hyj6nongQBTKMH/mZf71QmYSCkliwcugJ592qL1oqBZLj9VlonTY+bkh4g5iNZENq0ofKberlID8/iS5kg0mzoB+/uQtGadN/uAjvcSU9rB22vXpGjffQOIV1nzPRKueFeZhQ6V4Afjrvktgh78/lXjrOwlgFtIgo1dZeK/KsxUjr3uQOBYKgxsWCaogHor9LUXN+74TJhj4vMepv0MfhGFzSP5zZzCjBikif5GQNM3NZbjIdSyuYt49L523fGFo8nCqP9oX33uhFMfOGEjfEuyBchQ50ssI3FNrlgmyuJrz0ZWZDmNh19eTaI8NHFkfi7XGSxWGyjxSqTQ6+3Y8B9yb9uFKU072Y0WgT4UAYwjIA6tRLi9fRa5LbGyBmwLQ0Am/ijUYBXQF9KP9jh5HtvzauTk53t4QNrMw/UIc49StoFVfDPuSfLiZuOcMFRwfUYhdKfxKW6aenxJEuVQwRAJSFxRRBvOBNOl16qxtpelWYQc0aeW0buXelR8ki2IBJVkWmrR1X1Xx7dq3LDAYxKL3fLLx61c7mZyaAByn28nsOSLoqWTcdCqJV+RxTFdZPUhxmnrlTwb3AnOgpAOWsX3JnJM+tGs7AXNrplZjdwW0v1bYo+Bn2J5Zazh65ewy94xjwM2tzdaO9nRcXuU4jTKdkz0aL2Qspxf3yFznMVjWXUWG8UKCXxLfmEPpD/sQNFa3juJ/euDlY4hLN2EXwHbpWrp5gNUgrkNAUq78upBTAG5cGQWVkOkrPH1eE/nSWPQjlsZ0e92qHJV064HU7DPweCkhN/hFU+V39DKZJ/FzibF6FNTtTUEXfOAj83YeP5UpEjYQhV4eHiwxuMQVEkIrIKFezChbF4czFFEhRk9Sub444/vLDn93d5GGlR6Tg4H4IIM4T/m0xqZfeEY5cAhhbPIAdWvkqfjWWmgHE7m39WQ2Jdq1UlI3l1w0nkJxm1wAVNDUkw4Z4HiXJ5T5bFwRnlAi6QEQrktIc2NQOBedcoTyYORFkieQ/C+p0pr446cYTRF3yJidf7NhTnczp9XyjK83rX9prZn509RR0n/uVruCYyiNYtK7s0H37hTs9zrZW8M7p+MfmyVD5rOm1wrQicshZ5XknwjbgnmbWeEvTdgXuH0Ua7dJfALlOteVzzBoTKGpGfbIW6IkgUtRPwe6orwGcD7vy8dlEvENtAA2pUr+9FopEV9I0NKi8PHHhdzvP4nHVXZ3pCEnwRsNdAxJubGzB0IfCkVgTEOI57PG3evReDRy/lOG31rPS6XenZ1k1ypHJ/+RoGmdfOGLmYb5ygP1fYIxLfROioIRmGD6mqP7iclYWuqSQGpRtD6xhqCRJi2WaloYIU/N+izXzCn5MoFBc6i+28wIBJhH0qVJ9HwZqFIiGZGg5fcw0+Z6hi+oHuez8gtiTtEWAbnG2gHBy8lcsLzir04m6NMPqoCcb7aJY9JrPH0nNzR41grzCq29ZkO4vq+p/tGUo3z/XK4oBlq6P7JmVELQ6RjOmDOGQmXrvmOASoJsjotfB0G2TgoHB5lk6RXuh0dhQed+A8WkBvujGf65lhySU1Rz3TO+ZwBEMYI/k2YfAZOVURLg+rom7wNObreyfDN8zOKhRP3FMjTe2YDAb/UeZqhsP1Jd2ec4bQU33Tixwngh++oKEPbE0BfINDmozqLWC/qsLrGkd4E3v1X3ErO8kf7b1TpfM7xhItKLXEq7SYmtNtxixyQu54qzDHw7Yow3w52nCmNMo6KqHvAIV7JrF1U59rLoK74ixneHj8J36R+cOxQovRErmOHx5IDfPgz4FNQbvHYLCHG5PwHFtK3Pk0l5KHlqABotlt10wHfxi+0tQ6N0kLA12794bvgyfl//pVtCVw+Au+lf7beVUXQ6uuqCbcUe+lnOeEB2HrD8YNzAtLVaZxGbw3gI4gn+nVLbGFHcC981ms5gFPVykQJwpLed1XGCsB4Ys6375Y1KVvu0fAH/PrWcjnrHIQJyYFyEejgk/zQGlXG9Z6whn7fXfzGmSZLy3sR8/NcJEynHQBHWUx2kQhnCfjsLpG5iOrZCev7EJDLO6vXqYLYP0s4ESL0RtrAU7KZ4tvcPVaVSr9diyadaxoGNMQa9SEtqnryZvz5Lq9ThINE++kSH5hfcVbs2snGQDdeSX2SojMQyOvSMYlvr2Pdw+MDAC1qsA9uHbC4ZY6KvhNDDO8pifbVf49cw4XQ5gR1HAdnpTU2LuK9C2IdQitEBp6tdtIW1P37PZnC1WoKLuULhu2NzTK16OgELjz5INClbDwMzxUPvknaMOQUGVAfe98OYhhDUlLBmslfHQgF4y0qU8O4KJ376vreu8IuoLzDvfy41fi83qqo9SIz3P2Axtrih1ieWpijGWQv56c56T1pnXEZqWOfaEcM0UKTkdEGJYhjBlTRryT5l7xtQ7i8F+/tEsBv0PnNynTdJz2obnc26Upa19zps2AI+kDB2SZOmbQwg5bs0+4K+2jC345j25LcedtAJTQ5UMUOGWtyLhsaAStgOQkQD76nF8nj1Qd2LQJ3ihFTf7UVw4g6/BeSKLGnThBFiKIEyA+NzPtUpX2t409xQTEeCoKhLjjBZOMguCP+YMaEh5w1v4sJ8IskgMMv82d28hcqK9xsQYBwHtKlIT25D+yyBe4hARfUVoqy2Tv6r6TAKlVVecTrjmVPDfGEFEzw6++MIjRlSvGGAJPllSnUlMKowpW4KGjeEHiP5pyXgBmOvMq8kqEmYVmeFPa9xajoaIRcHA0XAHhH7fgy1ISfVOW77YHGn/TavqBmtDIGv59OdAj2gKnQTZ1gO+Kuqw3h/yneFTCY5XKASJ8asbN5blgEfD2fWbvaNXacPEuvHEy7N3yEJcbMumX0ya++O4zS431c271hU7hts1tS750PwesiDw1L7tW4PlnAvZ8+n5oxYawnAIklEKd1s9SXdex7A7yjC69tljP8OOrF0JPIbbP8Oitohgg/9sm9KY/nskHHd8U+2mm0NiUzu7AvPDTeSJGqMMojb2MIre+44XYWRfehArgY/HDuxjJmX5uOa/p5lJZ36P7dC0zZlGHhsvopJc6Gzc0S+voyJPNeVFx3jmHeOzaGvwh3kF7ZOC+p0qLDzsEBZob1XThXGjTucAAz+C3wzI+Qp+YIElnYd/y1/GtWXgV3fYC3pmQ3v2nmWFmnxPDmz9iAECoW7/qIxQls4zxQQyvLvFzsDNu5QZYtRj095gnyTTD9l7sLkzmEt8OFKGky7mv6nJHqqoqXnBQ77cbrZ/nYEHLpfGlz1J4JgoEdhH4VDf4BOB37r6QmJgsGcCv4FvXCaohR6VASl3VjUfrjROxL7bKtFW/vR+0bpsh+FvhfUxcB+398VJfulK7rNDXystjMH+is8QVIP5feTR9iucKOuSxntmCjQjw4SvlLOJhaBjxx9G2AOqIAPdOuT7/SNl7j4F/slWtTZNsTHlmqoapZejhwDkJvd7r3AQ+Nq2vNUiq+ntWOQJ75VpUyieoTUYvCtBhbkShJBhZwNgIwh7TWapK0PcEXYM7SI4dY358onw0QlONDo8tC02dzQd7nju2zbY7mSwwk3hZmqYRk8doNvOzAjt2PHSha/H9QuN3h+9i3N2grycdhVlJHMH2vGv7rqub4fQ3ZP7J3Xb9gJ4Ik9OKCeL7xxJ1FHKTQXu/C2T91GSa/Sn6RbdLBn1YSWWfUrypbGXkrcsKFvOkXbIV1sDNgjLaxiv5GctIf5dt8JcV+tJe07DbcsgWj4E6LFxAX1g3c5F1LZHqoKibeWykpCh9EwMH/holqqyCRL1rLE2x2USHbfsVwvLs2ILJJ/F3li/tm9Y002Q2Xx3hcZ6fI4LdKKxeZclWD++eRDFviYcBvotRTYFXFQ3Pxwz7g0YHN4ifTuhpOI5hXnYg56nUZrDsgbvD6VtGWBfh/WpPqNSgjft97LrX5poF826hVy4hDc8LgoUnfyOWULU5+WAF99P6Y/jJCsV7zxfhYqBN1KW8xX/hZNhtJ3JVRZ3QmPUO7OQ9NMh1+SOug99M5tPMBe2dPaqZry/E3Uw/eNwuOCOc5qW0/Tv3wotXIW3P1ziBBMIPVvmfXgibc8sBvx5uMe5Rz/AKUPL+2Z4Waho6h/IaO2EEVuH6+jWTIYDxkC0mzuOeXbiy0ITgkm40TWMAK+exvKFYTnXjcSvLjZurcEwRMnR9cmk/sNf2ru718xGwQe0/DvxoYFNOOxJnL97ydnUaQkTAE5f9rIPT+CnNQI0mT9P312nt/GcYR4xyIfdp6ppWlzx73+++PowqF0V5/sEjdA6cQU8nlZFfsXMlWYBrIa88sNWGnHTM5Q/Gins1h/NmWv6JELiTe+63U8xgyn6WZtxCcEFsiR9Eg0pPYcHRl33ulrZXLlw2LpXiO4X1OMn5hPaY6jl2C3cflwsu7P76BewxUIggJtwBDsFVh5gvfxpuvOYddi8AEmM245qgIJZ9ilZys+pmpt0B/FUqI60KQBS8GWnBfNjXjVtFVSwrT2ZfQ451iGtNZpFlQ9+DvKbA4GseCQ5FZJ8m1u0sexHkB1ssX8ObSeF7NM8FnsdrqD7ZpQLPwSA+h9U99fNUzyTPj3VZ8Pt7B3AB9gFLDJkD4PDV4cEzALnqDZ00MBLe38/sjT1MyOroAKVaFQsAetQ76jH9KBozRGRNpAxRFz9i9lu5SYkIzNFM7tZSnzcpr/hPnv0fX+uFA9/aYF1yvTJbT3+ylvjAaBmB4APj4zU5aztXTQeJ27KmlJjM/Ao9UA+SDORMR/Ou+UTjd2wZ8494MAFtLKpzYx63GHIuGSvMYSUwmOLXN/fhVatFtJYwPgcLzZWH5ztSeiODiO3GEXqe916dfXzEceTD2xu9KbY92UNMFz0OxyXboTie1VeND/xQpLsFogDNUN13Mph3OkkTBs4Vn33aFico+ESyJKGD2UGv4Y03i86Sb9VkXa5CQWmInu6r/lZu+HsyauRHE/aSgMDmGVnJlyha34hQQtAV7mU110XNLHVv/5lLDzeoHaUtS0L1YptFuXk/A2Vr8W3gIMD90Juhddi11zG9G3EMWDDuHGxEH8z6utcFSN98xk1aZOEf1zmbgxgQuRPcbOBZgs0RwmrAnYUPl6sMToCoPhd79SYpKvIP5k3iuA9GbrRukXOOnJODlK1niqs0sMt8R5OyyvJUaZo30tZupksd1+1VZdMZVjIJiGdzyXL8V+2i0m6d2Pr4SsORdaTFfaeCo1BHskm/9JN1HlV1r34pVwYO++9d6n3HPTBt2Ha/BFgT2t6BvPttOmHHH5gRj+i2gLvw8VcFTd8QV4UzXBv4YIMrEpsWhGoRiJ1d+DZWnGy+NEkzB7S1Gpa93/zBglKziYjUh9wOzyvBPBO7b53neJG0SWN4l/Y6t0fHQdOxuTToYv6qAt/wTW7fGJ2OntaRAGS869E51j7YNpaiMJycn3Yc2Juuq7a97bbJ3FTsU28jxfkaIgPGbDXl2AC3rwlv/pQILp7KQuHWjXiJHgyaffbk+lzbxwEsUZax7VfUG1Vlzlby3DVUba/VbfYU1JjbWPBPdSugP8lRm8vW/ymGdncC6rSm+XG/OcdWGz+Mo7bNe46p5ANktQr3tJWgqiMOq6PyweDmfQnrMVCbRqVAsr5j55ifZ0G4A5q32yy/H2TafPB8iWFscRVnAxa5vCTo8AENY+AwgOmAmOabt+HwV9l8yIirG8bDXc5VlXIVwfhzRli23Tbo03nrt00DslU7redNY3Y7GiXRE1iR2giWf3F1u2kLobpd9GcboPBorxoixTOL65ccqrXUsdj7brEBkvwx64PvLPA7544n0OBWAPWn2ojv9fLfaIE+XxAbHhYp8E0N5bAktUlaPOsg+1uxVhmEkIoyBG0YiAPEA0j4hmS6qDtnwatUJC3QGD0F5Mw7zoGgOlaDAGnbmAOHjAnwiZ1neB7ZmZN4bO5ZBKnr+Xnmd5DvcByHGAUfsvMcU+gpYNRoM47HaYZpVxHhWgYdnuQ8RxUjHlgmcobZVdyGOHC8nCR+NByVx7/qCzBVQD1ZEDzt4fzUBHDf4wyje3k+67x2p0L/lL+/7A8iCuJraQqP44OBBW34WuHvEhmfa2ol5L1CzPwT1bh1jMKgalB/jwHfhQeySPh+l4LXu+S54L6K6LCF9XZ2GfNxY1QBQpvkq7e/l/cqCnbAFQW8hrEHEl0lCBxoZR9URSi3TgrerrkDIPzx7x67vPb4e81MhjJfDBdCjPtcw40HYKDQ2j1xotv2ARAziij6ymB0vlQwCLc5KTaef38Pno89MBwXAA2kz6106+F4gDFMhVhZHqRyOGX0JpWHs36zZr1TS0VBaIrMIVrHsZeNhq2FwBmeaMu2fjGwe4QJAtqi8wn43IzuRxgU4ynC0xQEqTSR4KLLnr0CfGpguvJHtZs13VU3FhOVWZnhKZ2vLwAkaYBkaY7k5scTXr9vLkhQHrPSa6HN+I96lJ8got2Y6Kg902fx0twsUeWKJHhcOLM/O2LYt+bdeqh8M4yoeiOVpewHnhkN+wHNcIUnkkq4NKKpDox4IRGI+J0uo91lqmD0VvaM/FZfrYHfeTyfTC1APx5bTivxEoWfzHxFUFFgjCSnb7rbMp74cBGGZg3yLc8Iij/79zXPK+dPqMhSEJTRuZGitb9EmMQRW8OdCeVGgxrq2XvU5Vq/XZb/VkE9M9E+kXBHNR5kAmc/7p76Wzs92bmIprXVQ/P7YeepnnAgV0Aw5r/iXnb+XKvqVOhV8HtGf1uOZg73lPbR5vndhrQvjY81Ae8MGfvcc5AOL01VwErF1KeLsYs0rDt6U0QlfkJ9DJvRZ5IfN9+KWPwFM7rWnbVyIiAd6ifelmdM27XUxkxYoq8GoznWLyR1ke1wq6oI9wWhVi3zq1rKc63jc4bcv7yYcaCxPCrpq5LzV69uhEohluOwG5tND4Z9H+y+vnT5igQ1ZNojv8BSQuZp2W0DCYtm85I7Vyw0dRAbc+PNTk6FkwfmrDXXqDILFjPzYLaXxSDSq/fFNMVNxABjd/QCtVgJqlw3Zb2pxHddTkBgxVt7+k/MNmroiql6PlW6IitgSXjXp9dsiow/VCfOGoAPv1PTX3RNCvuZAai52o99qHRA8PihqsGYQV4ZaQhIyWt4fYO0X4WmiYl1hU0T7Ek5scS/1BCF5w1iDstb1Nc5DxfeEi0V4BfIaNqY3R07asQpikGyiXPzUv6M8XBW0t5B/fiYx4wHvyppaRsJfgrx/JO61FDIFY0/zxl8BSMhngSVQhkEDbxt1k9tzYvcINnu+3wXLMgZDNjC7U4cQc1bSOlHHx7LidlWCN28OHqt8Em41YSeCC29OiuR5cW5GMLEWoL1bXXinnAAWIuonTquQDnxdEZQ5M6AXJK3DknsjbWaX1XUv3pd+GszQJ99+XTqd5MI0/M1rXBsjNY8ICbmcAxMlvQKGxhZaHaGIPXgGbSqG6PG6+YbvU+b/ZP+qgh/dyiOUuSgF6gzhd3z1OFfU6VYbEXq23ylq35OvrqFKt2AI5Wzml5sV+93PQV8iyqviYglvJEF1LX7owpPNh/wHI+zJwHeBnXAvmhc1ygU0V55GPa7gkwc7WEJ8TTDjRIm/BW5r+hR65pKYPcDlrxyfbp60SbeL/JZRwD8pId8od6jz/IUVfBJw6rn7EdHu9XADkPrCvBOczOxxj8tYuz81YAzCBKqQ761skj+XB8ROeeMiL24EFFuCyDSPuso76yNOCQvQEEOgHxfPZxqMA2KVFrd0DsGoEOPefKXnzrp5aziJJwf6iz0ctr+uBX3bTfSQ7HOugeMlgds7u0OEh4WIR2CGwY/TAUVa6Gid0Knz3O5ocxynNB4iQCuivEPlqbJ6NlaG1UA32/8H5F5wQVNH0jjNy5apgmPddRB3fE8yDeEUtPreCLNeWSZPnEMauCxw4EnZMXKMF6Dnx49AxvCQdyTt/IONcUNmemvKO3IULlP5a1zHcD+wYypxKW7/GSpW8gHcJ0cbwHsj5GvgmoeF+Sd/lSRoa/lQnzFw5qk2OSJtVUkQcGV1iEsqnEJadY3UpM9129o1K1/naciYK+nBheHL9ZptI41ibXFb3FGsrGLYex5OG6Qx33wKcuq2YsuSQkBPwyFLQmbjlF8NA7Y58l+Gv/+HJr4WD1/xL20WZ+tJgraVw8Jp6HrUUaHd5ldmJE0ThMBGWsDiR8XsdQ62stMHlGsL9uYi3qtlnBZGum5dt8+deM/UL/VLfkT2WahcKyOrH9irEIJv9Om+LktR8KaZMImjPayX6lslUNRRESEmUtd+c0E68bwU4R9XXa1B8FdcCzOeXEY9DvnSOkWAkwp0hnS14fbt981cBaDNYKRePjyvPLJlj+e19fZl+c1p+e9vX/1PCI1O+HWC7z2H2AKuYFiUaqM+QwRDB1Cwti/Xf6N905LRJSYCF9Kmw89rztPI+g6Zz/VDGerrsTW363iu6dDV0VZTFrH3ehi4wuFx8T3uH53R5bPlybkfgzh9wXVriIC0+u13Kfp9tP18rrzJUkmkA9jQQZN/vJGydSgb7DWQFGG9KHBXU1RaKCKylxfwn/QxUSaFvR8uK3TKyHwQEZnnftWFexgv87vz6jm9ulwdAKV3RY9hPbEWx0Ei44AfxXOw/1rfqB1whkiMGMkP+IVzs+Bcvp3J6/qXA+umvElzLLF1zTi0yH88CJkMFe1e85VF/icGbPbue+A4MsqBwLLmLAerVCKFtvxI7YcT720TgzY2x+Y163+Yf5rRuqPebHWIsmU9M5+4uU+NbIGyCwGyOzrENG+fuqKDjwNcgtYcf244cYdfcKp/buIWrX6LOcGiSzsFAFoBWaITHmlMwgnZ9+dAMcuea9o3ZFwFMi46Evrq495laxzNOKRLD3tpv0goVM3FJ72ONQOYa9+nGp/6LoTOP8AMVAY3ZGl4PaGUhkfh0jFHpZFkec/EyjgBge18jMdGjMauLnvpRjxeAAW8zx1SjQzFbp8v9xwNLrPr94r+eRBznWgWMsCCL0gLw+U+OkGGSwQTd9m08efAJp75IanbZZlAtWTnKyJ0FZD6T/pn0NwF1qVHVNiiwS4LKZ32UdkfUY215CSE1O2kYmoe3Us8oM8AJw2gQ+jrcit9XdtROzPuAaokJOHJ7qKUWv/rngXTx65BDbsufNwbY+S9YkQjSzJ3/dg8SvCMJ97Fht+otA6cVDsgI3uAulIrEUE+3ZCbCLjUrNv1h8QE3j1L5Fv6VOpa+h0GYxhDkzAvldFfnpRWzdBS8613PQYFQXSNitTHZar+6A/qYnimBYQmHYFYRA8NYSbwReCVXd91V3fJ5HZG63Oo8MJS6voKYiX0AJxuw/e/fa7/+868jB07ArsLdEc/LmeG61U7YnVBY7gR4K2NXpadH8cGiRxrE8Jl+UyNfGZ8PrzfGvtrMltKv61FTq2zUHJnJ2AlU95l2yzCHsdzhNdqiwwgXu9oW16yngodCooiCYmGKLz1SJ+UoY+hXfYPdOKUXg2zUafjSbd1zAFKgOrFqEFrWoO2tTouu8ehguVZvN+qvSP/nlWP3H9SBCEMrX8wXEAIa9IYKvZN067xLB+wB95qV3I8qeX6Ml/9XOA9C4+/Ivxisg/YpXgTpyqNnOSFsPo/4WJNdc65P4rE2Ou071EjcBgR+qgSygYysZY7qHNz53IbzwNbDeOuA/IC6ZAPrCfTvl3coFd3VE/S91NCpLztHs7jq/W4/TA+dJVnc18545Ce6hfXnn/9krb/VevZPbUbJ67zP8HFRKLB8hBKCjimtdCMhqG3nMT06mOeGixb9sod8rL3HFUQ3tpMo0wmLCEHRn0yz7al1zPvxDns7Bf71RcX0PwLGeJwGNipnMtL7z6rK9JbTv/eKp2efDf8HPAHpt2TVaiIOdN04xfcZIqe4LUPBZzICzIZFwdCH/73s2OVGgqH1N0ddlYvLgfY3/a4n/CcxJvKkAW/7rKsqcEFGZDcOWb6H33edqXox4PeUGZrz7PU1jrGOqdQwzydpY6sMlPqzCxTVeK86WBXHjEvhFflUiGdWWUQmMdwrvXzPX9KBCD6nISzBGuXUj5V7+VZckgqlUztpBZuuCqDi9QYNuQDTKsriEVEvZfiuIqzx+GNAzOXZvHQbMDbrpny2h5AM8HgJlAaaY/QKRZ/1CnF67yUh30O2kzXPcj8lmIctS+MYZWs7kO7/KcPQMvwHptxzowViAYrhTzoZ6PCcD+9vnaWE2QDcZ2ho0wxxM9xFI4xfZQw3O6pCdIHtYjeuyC1nWTd0/Gi6l3/ZHgOpkv1UI43Ax79WqS5u8P86fH7GJ9sV1XN7hNw/SfeGRL0Kqvk4Zmzi0JKH354zwvpXh+JybWGBnsmSRizLAfaLs3vmlqWhIi5RQjbYTqL3+sjIBKG8Z+AP97nG35LpGE9po6ZOne1i/9KueuvZ1C5T83cFQipMfHuL+b/aoujFjcHVglN2qcwurga2ZvXA2Aqd0PGdVcaaRGf88kiCp6K+hbVfwHWrJZA1i6Qn0sckmR6DaQAaCUVPGaDkNMFyAbXV0AUzEz9TiK4d0HyAGSOtsV7DV+GxoV5JhyzVm8LO/gaRylvD9AqlE5/mqL0vOMKi+tENXY1lQHezRiUPkHSxqoC/MSogqiAVWWIJu6b5WFW6/WtVRM/HvJKvlgp9c9Owy5NW+yYuzrDzcabp1MVDB+45/P2CrxjJHn/nMpXKYfcl8ZhTiyCIerBbXJATPwB62lyKWOkJh82RsoHVH4Q+CvAAy42DCIfUgOcnd9zw0uwz+7TSJBlYFX3TxACAD0e0W2AxtevzxQvhj88L0Lj/H+IJEkY+AeqeXdXwfSlKlBe3Cz9oJR8p7fI9v6xvdicYFVhyQPyjHdFv7pJoaTz2JzWq21t0UK0f300OjzsLPorhBTN/U9dGwqdW5mvFV6eFwsxgeP/Cf9DFzCHk6GGoLsOySxaWKCC8mBFG1LsPMroQPEEBODWzfHzqwRZfIv8Trzgr0RdCBu9k1p3Gt7NE8pzWCNZXFUQ+iYlRn7GjixkShp/ThRu3vl+QZxLnrZArYrWMBxMx+2zjJwLAo4FkI2RvZP/zpnAa6TKVAasFbdkH70eCu0EJjXwTetvrzmDDDlP2uF0NZEnzz8Wf2XnMs1FgZ7X/6jnLuOIUbBnUGQQZN+6blV+Uye3g9zNEpdvRHJx48HJCGCzMNMCXm4wKltuwIxqj9jFBV9zODXqgzRAtguiNpb+cnYc11oFICYKnOE4FyMpgKj9voDZz41HuE41etBXQ5sDoRM7zgGGSr54mqqih4PQYUsaxVOxUNoIQ6GFXPmg7chL/lX7tcd6YG4Vmq8Tdh6W7HXU6EiY2qQEarVNw4pflTR53o9O7S/K9r2uzpaQVbM4188VZXBZMGclX7y3YdmBkvP7j6eC+12/KxoJwLUi7j3tM1F8B8oBN1Z08ZYkhGWEK4Iu+HI4hmp7MCuPOXdvD1FezDjY0XUCGoESsuENonQF/Ikxd7Wwu6ivFFRlq7kuvyaPQA4AEX7LEvVx7NcwmME00RgjVoK5ip1lSvsZYd8/npD5uD5szJFvaiPKwnXP9G3MW47JCceZK/jSDJr7J2OMRvuEyD5MYviC622zCh/aVXQ1dkJHtYsuZL/9QZJ4G/4aVNldtp/U2wkExBZjE+/oingNHIj32MWzwcLP49Of5YxKQmThUPscxUKHZvSLZHH1peDaWOkHrtLEzek10ghLac3VCHGEV3Fg6fhPAd4tI5DSM5EHBbhPAyZ7cZqy1GUpG32YETbdxqtJ4dgv9sv1mnDlIWRB0LmfUsnPWXWvT770gCoQ9k6S+G0np1i5AeDv7i9oQPBf8G+TVgV0jCIMRxEH3Goppqv7W51CEm1Lnwflkoo3aQz79yGAJ2322NmFwzG97eQANYK18xABo0Kibm+z63hw025HpGR5IeVpLty3WUlmZrkOohkkb91tQ0QcTYRotDt53kKTFXozgQv4nSmo8vXbjzKDD93Tmnvh58rV9ZVoD7HoPThmlSlIk8D+maAVHYRTdEtXQ/7FQmwDp1BeXf7RGFaE6bMeA1zhTtwRBt1b2xcyaTM8I/Dy5tbG822Y14Z/EqSvblNEn08kiSbvDZJHrKV6c54EN6Gqw/0XOCK4tvuO0LncVTVw0Ff8vbJ6Ljiek19aQHRiymazc14P75MimLTCoUGw0czca1krYrYc0dWBtKohj6r1I4J2UcmEchRP0K2WhV9Jrb7mdkEVmqyDYcLLqR4r2OWzVnHX0bWUYiRfuFIx45KUGJqvjFyf87YPX9UWGLvCVYBAIu2tc6NusRkg3GNWFawjMPOaUSnlYwnAZQyl3h4I8519sijA145w7HqkNe7jR03eViWU6NMvt2BI2ON/t1G0vovSd+9dhF8LL6e+5KpmWK28UnjBITiKzlH5mXIEMARmTtN4Cx7dRJojcgQY/rDl17KFfV0PfhAv0WkFfD6QEewV2AjGQDfccMs42s5o3QBo/RtVn5HaRXGxIVGxiaNqy3NcKdZ2C7L8hE715uoLsvkI7vc7OVAXApOHrPlzohjJ8pC0s2OHZjuGjV/tcjEDkqwUa+l0QdTDWmcqvUtp0VzcwjvtO0pQBFScmpvw7qo5YIUArRrICXZbkixND2OIQJJxcs28FGocZrFEW3bC3/42sC2kmdYCPXuzcLV7TiJ9lv2moe83OB+7G2wrAPbiFErBqlFTNsHGZb1eEHMMsr5zFMArkBjR2mknRf4l2cIrXYpz88l59AyGFbKPuaiuQgO9/TiDaiSGcRwfg6GNv/7Oc6nIuU88Z95cWwLwDev5XNazZS+gDHB8LYPrTo5pXcWehFKKryN6oj35G23jnzDTUrADGvzSyF69m6GJSLQ8kfEoictmxPT98hdTnjwjHjXV25qx8/crBBdQwzer1xwB1/Xrewy+tdoWZKwbPC+utWxXEsxfn1gmJG8qwD88MkyzLnJ3JXEvGm6glgqVzKy385b5iFMBLw/sotjBrMC/xPLXFgxTSL68HsGVbbFsx6TpwQMRcON0vkraqHGB8VBUPvQix6GAAQ9WBJEZf9jW/mdAS7GAsfcLg7j4xKYxwq5BI9zu2aoH989dd1s+vHOIHgH9joU7jae54DkMtPoNLOy/sGUjh5pPgts6YyT1WSBUXq6zCblSzBAfzsnCP5SMmcwQcYGmwPRVR4f5r9/y37SLHsXCfdjNi74WGV8asN+km4nOSMtJnyUv4zMRtP1qhsm03TRzBuODwM7NwmnIbAKsTRbfM0Y58DuKEbp4jSWtq9E5ZDwJX5/JM6hTuk1EcflxXjYB0m0bPte0Sa9AkvqSl2naZtsX0Zzv9YvN0QvTKlxX0pjd1v1HwT2g8F/1kH+b98rohqgLr1rhEB2MgD3n8p97xjup7+RfYgWhBDv91i6lOKJgT2YWZI66YDhdEhqX2uDpmr8VHbx9+7hv74XdsR51UoT4lr4uWonM5ckxNZIl1+xjlGpj/nyA0SECUFGDy6UeUhvUrRfT9ij1GlOba//N/cj3oo37CLzCgrENOOnmolxjegJqd75l4+mMbh+ZP68Kcy18EUAm9b0iqLoTyfAF4b80vO/Naj/3XuAVkPtNwzCi739ui+tiK0HBVd57PmjR8vtXDHsNshtF9f7d+1bew2/9ei/bSk/vdyPUiqUG/5MjEL7ubv3g7PKWVyTmhhKT0Swfq4bUqP0D/oszwYdX7Zq5h/+7d/vbTVkOA8tkTz9R/sQrFacE/llB589zRbPiR08Cn3HU9Cn1XAJ1DQ3RHJb6/9gNr763VyV4QhvrEvk8XOfjwZu/NaBXFNlHyGKHmeihfGqWFSHH6B61e1w265/OhPf76nSubp6zB4rxVs/d+tcSR5yDGmnUqMVMYxctN+qKFvbXHCFnbCu8nD55P9gLL7m4FTX3yEWKZb2Yy8vW1VXrAj0IUvXioPYMSovPhXr1vKprpDDvYcsqrVfFqbZurpQrx1qCFqc1dk/GKsiAqgHkCSsKyjl5zpc0Ip6Iry2YN6Hbr8CApmX/UE+nqJde3DN6vOqe8KvJ220VhMSHO4Jyhc9yuIva8Z+emn+5qzBWiSIvGw9337ZwrW/qtoOANowY+8D0b9PepfqSa3AjZzKZne7dcKva2mh0Zmo7xpX2NbYe/71+fFcg3rl2brq/2Q2pcsNxpubt95+Kl0IV1yiJ0ndZfu52dZYTWfx1Xi152Iy4F/l7nXmryuWWinyURg/2MkPhMhd6w/Cw80yeY38J1ZWw3r9VVZRWKcXBfSSWLfahiQzOMht5K1wG3tiM48SuxAlAvcR5xHp2vVU15OTDqEQtVLe/LSH93mciZiPuktSXz1DF24V18eLWyCEunIQUR+fd8qJucTUX/OhPF8hP/19rxE5K+D3N7eeGie8PV1NCM/ynGdsa7ovMXhQDU4rsvbbN59Prkhq+I1UkLP511WAh/GaaskQT06fwcfQZ1KdYmODYCSKqcnfn32dn7hzhyoytwlwnQkpa3EvGbgyoLnAtbTPV2uosKLupgNHx7B3pHgEUC6YiaGgrr98thdVhdBvCvEedMZMQnExXwBFxTavtlz3t8cQ6hDCxaiKVx2V35149atBnqJXdzW5CeeG9jzNHtWUEbvcyQVww2tBwUw+QF7IC6+fawKHgqcroVRgf/KGLOMb0kp0rr/uDT7bekUzj3+WNUSoYxv1mSEGJ7pokWOlz3gmdRk/lBXeWenCEZ+Oca/K6adWG4lqYeaL2DWUOhFYGWZTV/7t+5iQs1t2ESTUhqVBfr1EqRxabhDdbRIQpLRiwvd7JwM3xZszsx68BWZUDuWGUH78w1IQdbsMFRPJM81BD3r62zpyzAxt9W9jDOOlwIhjkk599gbcQiHQSylEbwugjvvmJQ8hZ2xL5j0YOY/PqBrY7zq7I3Nn86rlnIdzy16EIWsLiNM/iNPd5Xz6B8Hbwhfu2Iq3usj9vVXFXHufVFkKrppKG9RU0PGP1V96TeX2HFKP4+bgh/pi3RuBGHn5T5CHyL1W4RU9zNdZ8XHVmRt5NWJwVnk/IzT7NBDuDVr20z1jl6l9ZdGDfsi/oqe61lBkxmZ+iLf8Ig+1ya/Pvx0pBO4JParLub76qb3fr++dVteMIIZ3gOMd1uemdfosAmfOaSdxVn9zDGatxMdgCrlRHRQiHIky8H8fjQiWPVym+lE1fef1j6f6VaM4w+wzycOZr/RMGUa3OHc5omDeGg5WzrriO7IImqi+4uisuw1YrL8fVF+1AMjtzCLxDqL+Axx9KWDleRYrMfqK/hEvRmwm7pGL7nrHavdz7syvzqcD3e+/Y/KFt8zeheXql+W+IglzbK3HaXu0Vja0/z6ixx4CZwhdDPHF2onPS5EXAMW2F6gUufsU08/YW5QCxC17JZILT1OR509dkmWvKobnE7GzpCi0v7Dlzk827NRC7m7/fP3M0nrjxHLB3O4CJaEQcBQr/Lhn3PubnhmogfoWno/NXtHLtmi1xCW3AvZKbRyWv5inmFRht8QXVt6WZ1XgimkedrK9Ok+T2nHC7mJ1p0MLHwxYIIAU17zfJxajz5ITlEmG71OkvDe28ZJy3Gacye6Sl9bwUF8+dOniefFhx08b8cH2G0U7asj5piPzfjQZ766Bp6uBmLogVLD/E6TNPnQL7SvRkKDoNKVhBQJNJxHi7NGBpsPlUER2tzadrLcRqSSKeKq5ERwt06ISFVu/1pNuyLlUPJ724NMufJqmkoRttuuTKtPBp3bs81AutFXGYUib0iLyfVHyLPNg7eP+3N83NJp2mfoaoTyePuAn2t7XmF7mufOoUJgu4E6gJUSd1f2eN764UE89w9V1bLDbG4mL/rrLf3tMoL9XEA8KCcW+4HXuztUWjsLTM+bO9W7Z6QT1Eq07I9blG6FZijWDT73AtbQsm0bMYvy/XyvMSKgsmYpCsv252MzzGRzLTzmwZeYs1qFKmMGKbk6SZ9liJtl9f7XH3H5ANi2uL/VW9bf688tu+Vew3rmcMdTGb/f1shrDP8wBOMQJeFYF5Xr2r/vg73q9+EY9GUU+jR7ObOTIvhUQWJEwu+f717sZw4N+8fiNGaoURFXcfoClL+Z6XN80jfTZDn4IOr5POojgN6igDOJr4H97qHwtOX9TxLK9UBpPIcXbZps5vfIYaeX/JB4usMsaVnnptB0MuIrMeggS5dyPgtT7lH+M74mQ42cWXtTjYpgjz2+AwJ6np8FyjjkgRLbEITT/bAkw34Y2SpFXfGlbPz0P4SYqTQngjn+VTa2//7r0Ui1lhfsNKDsVdAZykNMHGcr178/PU2Que8ghkcFW4zzsYTLJ9wcUrL9n78q93p6AqVlodzPnDrbUSbWiolLtdOyyjO64UZ6OBnAJfzr6Sw+/ex+PdW2OjZhcEkt1w6AwN2U/WJzW27atx8jQVVVNEI4TX5vq8mo6IVSoj/6egRxtnP2l1GVwfuFyoXs8+E+nuVLqb+qNNJszwzWuf9txp6/+iRpwR6+9oD1UgESyf9Hi1KASTZEiy1GxKj8li0XxDQdbNAMdlsck1pWMBNOyVGW8tAcZwBI1TdMUXT7i993utYD27ZvEJm7unLt2S4bSgpFx9xbuRcBUq0P55+pHEvWWkCg0uYOKaXUdIDD6jTTLqypQzBa4ewNXK95783G3FIr955nUB5l0oIOvkKL+5NKj5QKWNHj92hIqGSumccfyWNKtgZsKz5LhLvcxwwzPmcDxtqguqN5liwV7pSQmTD949E5RBPzMEMa08bkXdBqjCgniiwq91bqX9S/9VZVsyBuiHPIGePyGpAzWP4o6GlecPYGU+Ib9L6rE6VwjFkIE8LqsGoldXiH33pSHjHiD9kxRuk72othKBcfHcYwoCMpzQYAlxzRasiiKjvHcoelozT5fKnx9cJ/vvohhHOxfSokoAX4P50+SyrsbpPfQfESDC1hK2j+3+p9oMA3shI2TgDCKDjKwlGt82PFgrA+ovDgsGF0lcWxP6lCSYZIkQQ4Mb5dFSSNdYxhG+3U2LP/5LAodm3xhyxasmfPWEw3EXyOa27IKpYTP9cWyZT7Xbf5yu0Z3b1+ljqFzuL/jad/vdUj91MNVMk0vGJ705XuVwqdwbtui9Ox7HN1HZlU3zfdxuB3UQTIdZcK69x3ugKu977n/GgzbyIZzOb75dZLmeJ7v9djv6NP9es5kzsMG7sQQfdl49nAciqDkBRs1qCjandkORPXTM6Gv143Yd63FXCfXL3coxiQAZHb/SGsUxKEYDRnuIWchi3r+NWKXLB08U3+kQ2wN/MSc4hTUxVqlNV0J7xg2teHTZnKzCDNGGyX87b/lf7irSjocxwU2TBLWGIKRorI0eXGWBKsv+nt/NMrv+9Js755+fJ/mv5ocQCyRoLhgfrbrP1JVr1YJZ3QTyqgcv3DjahTXt4pX3ZfJXOVS6VqGg+ccOGbe1jVbsywL/x0HAdt+8Hih7/vacq8nYKiUx9+S4Nf1uU0+kYA9t6FrPJUoZDA/cq3137Pzq3gABtQAnJ2UlNOTnctQjr5U2bj5r2rtc+rsX8b1w48+cBvuEL/1Hs76kGO2C7ewMFS5gq5JdRfftjxt9tDFTDUusdt5lrIKRTVcAthl2E0Ku2BvhcF7t85wZhQXT0lGWN7fgqq+mitBv2+mhdBSaVjwe+w23qa2UTViUxREnYU7jbsBrMQumM7fHfKn/+OrI2LJ7sxNhrtwyT2KvmR/XKSNLfBYOQY9jBxEMpYp3MW2C9IdVbohTQP0dTYaPLT/ce5WpVA3AUPyD8lL7sfMyAtLF6K34y/9u1ZE/USWku7q4BxZ7GuPQBa4i/Kbm1fiEd+CTEoxrRmiRn7t7m7WxIX7t1hWvh7W2LG0DJ3y3AcrPzY/r6MH6+QCNXHBIwxRxXiTbSuuAPHOjNRTqoxBBdv8GjkTrr2l7zMdQ4RBH88t5J1mEiqAXKL7sWdlv0uC+/IAR/60z+B4T8oQlbq8vEmSWnKWgCSprpcaeemL83jCavrWqo+sHNGPrHpZNuK+e7lylGX9z2MnWb/HzlkUQ+pqonqeuF4hZCLcTn5BHIPoiIwyGAMsPI89m6JyjiSD9JWNZohvUBB7lrNzFjJrfgPpVpzvaj7o9/owS+v6+i88Q7x4I+F4cUnefaL6/6S917ajSrYt+jXnHW8eBcIKJzy84T0IhP/6QzAzs2rXrqw67d71km1NSZiIEWP0PizH045i5CPeDxjF4p5BxqjKk+Bcg1BKm8mfIEAQsg1gah1gmiKX0jPB+DXGNND0WHO7RKra2xHaKFcYK0uYhqZa/M4mIfRJcDYvI3eiA+pvuXqOel+xBl0LzrqmbpnvcPU+KNq2JOT5uj7jUJp4df7nWsXckDO0mgV6+NxnYFJOH2b/3+QSQvZZ3L+8M90+fKl9GDQNp/lygeZiDfqSQuE+Mb7n5IPaMj+1hwXZ7y5aI6h6nPJBIkzq911gGQcYpJQdumbLHK/CFMkj51/1xrOw2UzXlOoc1faJVQUIXhvvQ4CI6kl//quVHh8USG0jatnL7oz3z/Xkl+7rs/G4PXpCcQC/kwyU4RJAf7pRvhhBhkGOVMn3pGTrLyNRda/PjSr69/FgEG/Spdbsa7TgAKYMS2bcCaYsBQlhQA2lO6dTmmaCGMfP9/tviOKJKZvcPp1sZzmF2l4L9+vs/0YVoklOvnPbZeru9Z8bvvS713+tMhdqs7VDPdx5/0Iw+7cV0su77/sWlqLhPx0cHRAQoLnW52MsHmenKrXM3PEM/sq4rl+/7ZcwYCAHf72eWAKI/TpFSN3mMMHjFy92v/co0NTNkCyv+E2QhCez0Z1HpkE6Aufdo3hwLfd2zeUEPSfv5F/+u2GBhuRgSjYDnJ+KQyTar8klU3iZ3IBI4wtA3bnV8Mv8w0YeQck9E/mx1iHdOCBH91NRn3uYpkUrzvPf47yj1CWQNacBPQiB0hrtzH11QAks6o+AgJrHpF2PLseokqkZoIt7TiPXM+jCZnDV36yvCmI/vxCG3X3e6l1veD1hqPOESQJEPe3NxVRCUPcGcnNFDvRu4RuEoi4bfHop8dx9J+p3DLu7cL6oLyAmVXUQkXuvjkIpnNAjkcuPeNK5d76DHtCX7qG+ld5LGE3P9bcEEkkFeXzxPNog+ocQL3nTgHvpdU0ShCS++CQF8Di/m3DmYHggTAzi9Ap7chiC7AgwDMfrZ3f7bB5YovMsgkyk8tOVrI/BLM0opdusc5MFDef0+wZtZO6cKH5t43sE6d6K0Zk2+rD90p0g7mY2wBNVbE4fW6415GDf7kKzf3M+2ADM9CssoC9hOKcTtDnyAj6fyOsIFe9SzbIrJQzmfzwTelB/kV6m0Itfskskp99+ePD36sa5fGW4LpaDzmxIdHH/MBqPBXhvkr2nFnrcoCewUcndeHDR6hPH6CwnsX61WPbAh9ZE+QaiaaLVQuHJ9a1PQsN+J0x9rYuXjVMqayAVSlpF3k0TyB+4Cz149YsgyWmy0puvBuKGoif5Aypj0xdf6g8yXAVDT1Q4omPIQY/BkL8X5yNCZv6uuc4pSISg6Ge6K2WAD+78vhqB2NbrsFRDNq0flA0cSseEMa6dKLDtuigvTS54XuISoD9mIg3E44810KT9YWtYGhKW8Xq2AOQGdpE+i3+LPe3r2xOVOL6+CkCjdvl5z5jF1OZHEzGV9z71fPf/g/e7YyE2fMNiAlwQiZH1ITSMtflmmaiUBoHKprS2f3UdAicI/k5MFTqXLiAvzaAhcESJNOVfFiPJ7ffg6CLsQpBsEt6Q6EKxJUY6F8Dl+AK+HmKD7GAv5ArpJ3iEcBRqoiBSjT4uQEbhGasEJAojMJLCjSH6DtyYCr8kFN4F6Ui8VXenqfvwhBiVyRR6mJ4Yon6m87yX//Jv6s7d3he71Zhpl+YEoE8o77NG3tMofvVguHRE3TBRa7277hPKRAqE7yBwzxtD7V/9CiM7MrTunT/DXesPczttGPXRO5sDuUl42b/PiiX8+2VL5dP3TF4EMQHT3ci/Wfy36ACveHJ3h1nGXm2yQbqtizwIRxLv46xe+oU6E9AGNGVWQTcm09zpFriGa0EjlduP5CPQ6U4K9asj0o92v8T6ohrZcf2eVGgsL9Ul/NlKBkdXaj1NwPGt1wfq+76s+CIifnUUpXC4ocBavGSZfJ3nSZBh+rzpmW58kWVd7QAcmKWqwR/puK6ncdSPu2+SsdZFAYfkraXMyFh+ngb8mKBqYiAIcroOSRtll+wLnK0QvNXpUjaC/OtFJntNM4wTIfEwjJfPsPPnSzd75NPXDyAFS2vk1BSDyGGeZaT+m9DzoDLlBFsO02man/R86eO2/56GrrNaHtueTsSKonwv0aWJlMTuKXZONLkj+qIgkD4qfWkfXHdyV4X6QKdhGLKe5oYiKyaxP5ZJqM31RN8LLOaI1Hp3x8Ls11CsLtd/Js9db8H5YDQaU5Idj62r8WRJGUbRb3+mzCU4P9moILDixw2rJ/b/+ZVtz+jGuRZWfumdwSC8OCYxPLx4khhsNrCV17qfeEvk4LbD8F2OZf3OyueTPe6SIrI3wgyZgAKMK+Gb5C88W/z4i5DgMNz+73sQE1ijmbRaiPcOcKn3r1bYoK6dR0F2AoU9slnRYEgMe0K3swot65gAqeQ2t2hDA8X21/odT2TaR9dbYXtirdN+XBPsyNFGQv8vHknb0t8TMD/1pzMpwuLuuHhzvN4n/zZzYoyggjZAIINX0CEbiEn8ALZwR2d4mn9KnQjsVRc8RX3+Kx9vnT88vDVF57ldek502DDTMDiu+Ff+tIGUk9AqYFqwb5pzvS+5C1NM4MHXkkh6LmuK/hVLylqDOsPKTcrm8zhOrPJ8X5OOaogZErmnV8OfxZW2S+9bWJTqHXkPa2PaMDP3QgnQ5cKNBm+N+a5/D6+xwIf7TXexrbMj4VB28jp+n5mmgjmPFVg3ZxHKHehIXgFfcxcSCG1KAMUJ/G9BA9vUZECPBf6R28FaIVmJcyfSYFO9kuUTKAfJJ9KatTEyXU5J7FEIAh4gflih7dlEuColHZCpKE1pIoy3VdisLlS/RiN0/bMskHmp/vhZDOU7KYYOBm/x4utzkr+y3g2+G0hBdXazn9rWzQ915pHYWK2WJJzunOo0eysUcum191SDTrEWTsZxTPUTS17SDASxrMr0sZFUPBHcHB12uN8lTBcaMC20DSM1tuyXba26uhzgxcf+Ov1TtRI8YWeaR0/3CHAqn8Ysy7pPBnx273pfPEFASIk2lxU86xlCxLT0/Xpd5AK9tguJxve21LlIrb6Tdf6SClF1adR8MPel6j76dE/YLF5BkjG/s06ezAVOEWN/yUs1gZXBFGg29P/hpRS6u6r2At16iXFEoxa52fg+GXP1ubsqCOzwTeJvbuKYUJVwY0M/xBcoEhf0pvybj0llMfbuizGczw3eZ36vg/Lu01Uk3M20CtI50Hkve+lcg5+etECpGdFRhCai5R8o+yaf1iIf1DMk3mDzKX/yZOiYPJaIf7aUQY0t2L9tkOTitYJARVIXOEJoWne996UQmcdaQaEkGPCJWFDNnbLOTGuRNE33OMIgwNXY8TP6iZ4/XeB6LcsvwHChsc80kuiaZzSZ+csfab4MEYKCFDN8HHQ41mtBl3j7YYj1EdxNUVHUT08JIOVA2NNXkFuKiRcj8B9xRewhTAzneUaiFKLMny8anL7bypKcGkZ3ei3QrYvBO3nUdQj5min/bjg5N6t0aKobmzBUyy/mM38MG55QilZ2Iw9BYY2TVjkYYEUiZDiP4eg1thPqw1zDApY8Ta2teYpC1iXKPwVevyyTW5PNJ4I9N879rhsCfo/2wL+zi1BO9z3a3onkUtqwmvpVB76LBEIMLSBevqztqdEfeITebpmCJ+h5XUnUzV97I8xzBadWlcSWP61fXIPlZ2Kept1V1Cto95ltagKEin4hd3XgobfbIt8uikAuPQN2Un4b4HSo/xOFSEfyVj5CXS5Wkmje3cuFDXlJRErwiEI/fmPTisU3QKKanD58YeU1N6B2ZerqSa3ff8vafg5mJc4gnpMeWnXP1YAl7YHYZfaJNJJSOTHBjOi0wQhWOj7Ct19O2AbtTp5enCw9gHsjB6ggvq5hKjApRh/1sqcMS+DCj7jSCJZ42gUTNoqMYm7Uzz9o42cL+NUCfckZH764nnzYNUHAv/DC7C+3psQ28pg9UTiMyL0YjSFd5kttPIGNOfqjfI+fr6tAZnF5rpWXZoB2sHSEZ2s80hYTdCeWXNqnzub10sjW46IMYCJJ8PXf6mvsYOuyobYWfEAOZhOF6rNDEtAIKskPMuataLwUxN1oAqCPswX1OrprtWp65+8RDR/odjrXcyptzhavdgeLa0MKkP1Y5zF16On67/tFeSobULuKbAXZo+5DIpM7uShGBT6rtQrwsdD0fk7eJj7LU6Iws8tGv+9RgOH8/TIQq/zFd2o6r3vkU9Pd3csuFEKGUf2COEG1cOjVeAoH+02eNsEEnkC+O47xqYYaS8j7veCGqkQQTdOgMTA4Z07Fpg44Srb+zsXnH+ajFvZXRVhY9TpiaYiTnJ8URmK/EFacY4rtUl7HA51x+0SOkeoTCDnvJNbRL9FX31btlzuRZBvM9/7wJuia5SLaARZLX+1SHYDPhK5H3hfRIX3Tdl5R6pZnpQv3GMjDz4ugCJMQokkFsGU+wJN65x+BNnDQJxM5cFCIJYnCFChmuP6vEebnQ3oAnkt89ecghX5jAk8VZXIXj8pFmDAYsBjkxwbHOzq3NFzQeF5VRdY10OnOWyzqKUDrhRcvJrHRZGzbXq+pUHvY9EqXRn0iiKAaKvpC3Hv8mhIOVnPXe/P+Wh/HL5EX6AnEkSlsFFlbBDeV/kSYqQwvqo08w/4jyWoZfKvPW60FDrhLjX/o5AUobuVS9vOcffT8VFlc0K81soEfCwq6lsRSEwG4noMFx469GAz4fSJa/eGLUhuhsVtjkYrgfD3JePG1Hl2oTWUJR5+cNLO/oQns016D+6rjEXpIsCEFujRYWcEClK8gO8UALLBCYNxMHuMzftnAtRdAKNrVRvNJaJI8T7xDJ9qS1M8FbJ6L9qWfekZTX2At2Ot0GtUmf16lxh11kFra3VDqYkMY5dMchjQr2OVwi8f3ZZ/8MEbb2FzlEzjdeXCz/9l9GHQqrZiF5T28zAOoE5oKrBPy3N27jRwlnY8XA4kY8aQl8bjbwktgt9GWDxh/rLL8S4aHooFwU1pnRKC1GU4ToguIe7jyC5kAl5M/xDxd9GufTSOdzJJqR+lxURJi98hs9UoYrpAOiyPkfk70S9BABLQCDX/YL6OcvsfhrE2iQlLKhNAHX+DQthfDX0fU94zrhdE57a/T+DYceVYuFi8CYD4apEmwHMtq1mUrvnD8oqfPc0iv/f6r5+3P35gHh00Lmi+xfhmNe1I2iDi2B21R5nBbJUO6OK1/HoaLYLv97qezRCdi3zU+FmqxY76J3ArPUanPZ1w6zU6VpjY614Y5FpLlRrcqGu8g0lFPJEGMy1yQfdd165H1P04BBmAYdP56tqrNQJBj/ohe5kDiZX2RDl/emwFChT4L+FLkUIMkLwjYt20w+c4PK8tmAqHLtKzYA4HIO1vFoeedGL8vx0+hLLdtwEFpVYiZr1K9wuhMhCSWQAdhhlJZyg89HT6NIt/pJbt74u7fAcRrfA1obRRobeviccqL7qoTpt7JL8YI7NmluQQS1C09IeDBm+QDTz54mpj7boRN3CDfOzbv9/jFVNl95jaMAqi6gTAicomQ1l+eh5AyDaza2dH0l44bH8Y8dSAvrtzNmYuX/EBR+17SFMWJmav98zkgzLKa0w3b7wkH+zyuQML2WYyeBPf8kxGmXDqysxF/iPLSH8Pjm/CjZD8m5iU99+5p9GMKPBBdtzUKTPfG2aEcdMCp7w1licz4JPUo+TFm53NffyJ5NblEzTZ4fEL1vqWbDQ3McJRxmXj1IDYIVYTjchS9vol64n2rb8BfAXf9XtJMJFU9+kQ9zwp62YaNbCd0nHeKYV4ffjAgetBy0oxdYqqTy/Yw6KMXU64WBvh0IzSggY39Xc331wiH2TUQQ9F0bKByU4nXLkmlsN74O2veLwYE7Y34HBGBsJWuFlH8AFooa4mLidumNwYnEVS+Tfe2LId584IE4SIf94RxXqPId6zuNIZJPIWA+eEMPXmUpwkOojZY14atouIgiTH9paXThZqGa5fqao1gkE33yvLFmXykSbflonyoYQdKjheJGpiUrgraoRuGIk0i4kJlmmUsn7O5egPsVl9r2JuDzXXS1Qb93Hlsj/eL8b2jF44Ll9ZASKEFd88nf8XxtYJdmTmFC/jnyNoDdgAACwF1E7TH4qhBADjMH+B0Y1x8wEik5eP5yWv9h6lIQqeKS3aHlP5p/u63zSxgawBtcxOl6j3FSCRwM63EOtILmGiGqSZi1x0va2jXkUXndM+sH10OVh8nUO80wTw1HoZvtoJ5X6C0kz955ibLLIPokQJwTLiwVZC20WZ0uhhvBpI+D7og/OMFk1C2WuG2Q/Ya9z6kFoMwU7U9BXiWBzWNJhMKYemCe1quEEb+vYDF7nNOntX8N7y9YJqCfwUjvOtX6VrwVmPYSUXSTDE3LzZxPueLKX1ICKPHKc8LYE8iJgM2VeqI514xRfe7cue/Zx48H9TDrpYTTAjj8LHgbg3LA6+hvwOrn6EOuf4h6rgkgMEaEYUmDZatIs8jaRksK8SKWvGRSyP5cjdRsI0A9hWVfwoSHHDP0Ch/+V7t08DP3xCBsXdjej3CMMAuTVx/QY7N6ZP3xDEGfoK8AgOAb5rqq8ytfOhuf0OqetmKG1mhmSFW6wt3RzhuRwx4dQguuf1GQIMLeyBqwe8YlSACFxtT1SRP4MAdranH8xtP5YWG91kYVAIphCFWf5SeRjeaRp9v/WISDRe+yq8csLamNsjqTWqvfWYeoR6FDmXUedYF0CGY2GFMTBatHrweyV43AX0JE+SYcYfdY8dxNsVXTwyeT8KsOsOJy+OeggXWBdXzQ+3Exx3FACSrZjvtCxsrggBc2afJG+df1PU+JBhglWZxg81tGWrrEHjtuAcNIPVGbLnIUokEpFz7WTbRcyWEC6HXH+t1cf1sWcge5Uq4vzB1FDd3k58v6FUhJEmcSINe/I4eWp3JsvPQ5t7nEqthqgIC8aJuVsrIjJjK5KXPdMhaVPzpJnqhYb7wLY17ppNvG37wLSGu+H+st3gE3FGOOlZkaUARSfmrU5L/ltvbuwNlKIwRR3ZRPLmUJ+/HlxyGymfqgQcUsSoA2iHf78sS4GBpk4/wGR6DPZ4CMmFSWH6Tu1uFT1MpjtA0jILa8kHikGckopoLdaMPnEDk2cMzjjU9+fqscxrf06/mnL2WnZeR8ymKM0+KDZSAwWKrfsBekxw8YPdABes8azMeDe3w3oxZkEhUqtfvVZwiHwIuSfCUFySAfiDBjxZmVGeFxpjK94JUyIvPtVEM+UGg3D1GAM9kUGpCj6Oaw5rPR5cabRfk0F0w5rWueRxl+fpkmCORKtRJ68fri3hNxBrj861NwmW6sgsF134HDMJlJEHQ8EIsJ7Xyx4KYYM4DnC4BiyJ0LooikDe3icsVJQl6viCTKbcZfH7KV4MLsBEgdn2BnddBA7QKb6QXJHa0HmVJRg48aRcKs+2o+/EvQHKd5CS3PJ0P6NkIqp/Y7ZcuKtXmYqTXNawyX4ozSEF6ypNc4iK5Vhj/jZb/P+UXgwxfvn9ymTG8G3O+MwDYUBmELemRbc3IFPlRCwiKo/qn2QUoxfpMt0kweSCZlPJH4eXDt7MF5WMpRllA8bOdZ3XxlNZC6viZneFysYGvNAii8eKRYYLtHiwuAcczEpbSB27YUms7xsvBPD94GbM9j57eXqA9eh6HtXu1hXSIEasqRUMon6seeqT16260nODod/jlU6/L/Mi9SyNhQ/idc/rUhK8tzzUHb7XxEbrkRQnR6jDDVxTRYwMIvkY31LnsytISMNnvhWoYMptf2i+KcdXCkMo2Z33/4xfkODYx1a/YuXbLI0fI9SH2/6fm7C6oYZkLtdaXdmUca7k/qyX3gdiAVrdfTHW8JkACjCSnKnudmw4FVqVZyvvSjLMTJzFEO9dJ385qd4sODwIT/yLDgIm/MzhYk5VcJ6zn9InxoXS8ItjzppDq7Mj9nbERyIG8c2AYo+a8/6lTQddIBdkgjUi6R05wH+BaGDBOLr5FXiW8wjXiyifvv/Y2YAPxfed9XKhYrzknez0eiiu3dzbFPjL4z84ZPGWvUePSQE7mkNyNmjDeDvdaubLOK8TL8t7F+n1+351wqmB7Dd7v97vOnGS55h7YFoDClukJAxJHjPRnrWvg0H98O1+h1nzJgIvK++ccl0/JAmSwy3tuqsa1E139muq3wB6MaplHrnjCv48MA+9K++gczcRA38UPnzvcPYvIeUa6i/vAauV53l/4ze1dSJ4vmnwhvPCZX3pS9eQm/HyLNxLPRzC5vw7T9NLssYC/UNAz/HD+vpN2yQRqbC8MycOWDYEaMIHq+xaWO54qswBSeRJ6Qz5Wp200nOys6TIWIVEXf2ryLyLGaslZ2N4Opoc/WOxbFqtuCn/TEV3zLsgCcbu3Iera0PB24YG6GhUDEdNT34lzvWOrzE/+58h2z9+Yqb44Cm8fghoiDbUKjO3Il/HrubGnd+hsMvtlnePjKdYqA7EvADYKg94xqrfFN6hWknhW2LjLgoTmz7RBMQwmxM3i5xNgIxWMurzAedZ5kbFpZ+IzUQ6oimzCCbUkLmTDZ/5EX1mwhjy8g5pODn+IQtdjdvOXs/nYvM2u0PJZE8KhLZHG35nQVhHv+MxvKXd/b33f/c07+fGPuqbriZiMWfjGW/GPpzV+GCSjH7sJfjEjVmLD9IP1mpt1dl5d62Wocqk19W6S60ZBeQzdeUYtm+4XlkHmzOjf742GJ72OosvA4H5/nnZC4ksHURmCohQICdMvgONC7wLcbEoDH29mL77vh6FBN+I5aR+IiIByrB+i8jF1PfMu4yN0NM7V9bkdAKSjEERqFzYMZpc9c1BZQE3n92c+JfAp+JeWFt0wv2D0r3h2csc/W68DM9BemosvwmUP8ZlGbIrRgGpfTlYHoX8G5cF8oecAuvf1G4xMf43NMz+x+d3Pl+RNpz8ahb3oL8DYB7GmAXFrS/Gny0tI4PxvGY2aprDNYdYRDpIrWwx1JqCdnQW1p7uK1hoLgK5bNhioQO0eBMOL38Ak8qdPfKUnpxYTAJO1kwdELldISKBfBSX3io5gpHkJp6frehS5MnQj0SgGDuAF+I3zA3gCedTNbJq+ZZS6oxk1iY3EBbs+YZody7qG5a7BBF4+JrSqquX44z98rvOs6YeEXWS0okkscPy0PXbNGDPqa5MniP6HMRr+tJMFP0m7toXJKBtto5O+vnKtO4B26JzH04+xXtZzHl+ZdRD4R/2aiO2Oj66lih52wXhm/gkTpej7kHQyGJN4NrLPm/nXTixD+/AS1cSQoiKe1L/iTQKG/1aRCUv4w+Yt44iUmUMLUQ+qIGQmENVMp0jS7Ca2XoxmAEV2Fon8kyNjMdVz3UE1vyy6s2Q/qMJ/p+KSrrnelHWYPhaUg2qJym9g+QjQWKvfan2daum0dHEaoBePx64v6/wnkXPnj58V+P2+u6brLN9TCYgm8718Zw4Y/IRe0o6eOZUkef/nF2QPMgwyuMouxAUzagaaFE/gzclA4OLU0x1IrLluc7zoT5/2YrirzkDVSWq0k/i3+mq56Ir4ddGO2IwCIppL/vFrMit76RoAaa0CSW49GjOPLUBBpmpr/ulvzFjMonNwrXHNUeHht7MGo6pU0PRgV5o7idmopbuy+WXLMlOd53PFceM7kcH7rT/se9oI7LLqQGRJEoCkodyNKIG+41Y3PgMSOkc+ddTf8NXNaXYpHuBrsvoqvtAsULXyeMl1muXcwFOxiIgXgEgyOP88ezLO8/N7oRbyTDim647nE1io7M6GUeRrfXYzzfuL/MGuhxe2cvTihSvTfprIm1GP6BfY/TOSDSiJTRO4L9LdGlswF6//5P08z8txuz+A7+f9ddecOgWgnUBVG0rdDBrYetqYd0y0Zm81dWYAM1yB1kN7EJf3gIS897r26YJnA+NcWv8bm/t+H+cXkBIwDoHHrTIyRsPQfxVEfF3ZnYPjrg0WxteHe0ji3eF5So83wfwN+z029cZDOdjioOe542IKzOfOIuMr860N2Oq9uPTWiZwqd4qYQRIPe48/EcTXm9uauuiI3emBNy2NcnlafCWlQbEmL5XOZZWuv9E9r1o/qLVhqbeqU/mDhyvrE20Lg727e5BBh6VDsRqRvd/1Zfk7JGRZUT69H8jFERC+hieLf2ezmE0vQr8qP1aS2K+zexrkDqKr1O5D3Lkv553tgUP+SnoeYEha0NLfnwU7ewNcZQY5Mv7Zn+PrUqodosxfKRD6nmXl8Z5kwxEiGNqL4AipiCIOfl0/jwb4VpYKpCvEscSB6S0MbfkgPQzIGHCShNAqcFFuW9ZBz51b1TYPpqu8k9h5p6S1nPG8IApwlSMIyHJktI4i3VLXDIGDlCjMLtz3298HnjdZBWu8fSuGBpTHel78g/q6iwF3j9rZ7CJSbbkKzpMd2e3mAMX+kmhKhLykow9I/BwB9RfcXzsdxITbLtaRgop+Rpnv1wNn1EmgsuCdsO/byZ9vtuoBgTMa+Pdkj1qVJQF6Nzu1sWMcrLKLPln70jFFRMAsHxq9/M8+AyYBc8yzkotIY29FEvzX7+15MsdndpAeXZclPbBM1zVtxztRQlyj090zfOuiPTrdudcZNF2YIk2vXW1Blx9e1yCHThwUI73bGYn4stjXReGfsZamybVKv/qUA4sU4sNMQN2SAM71jrGvaxD6RQq/t2fhI5uzEMfT8JkAv0gyy604zUGGI33gtfhEL/toHPOULmj6AS4L9M74gcHGkylOktSFhfzrnfJ+4q8TPhuvRqYRdaBFkRD1LyR/LHWcrp1EQV8WkD5oF0RefKL/yo6FYvytoT/VxCaXLn1rkGQ+AWxifiZiKOEigem4PDjZ0VvuJiCY/9TRlx24QIS26PMF31cFD7pgxVf4KpzYdk1QnGODgaC7VP6zN3IFdfNrUGnaRIMYBW0cfyLtpCJszRnClzrP4ygCjcQpDSi9FXgl0nJ4nCd+CnkFNdeVHg/LMRkX1HHSENCH7eebbMYYOppECtFomj5xvpGQXNY/7kaS+wX0WkO5LKogeBlEUr9aWF9KFF98tlA+9Y6RVBZeWAKtyzKNazBJ9yBz8TX6n8wdGI33PPs1PS/sB+rKfPtS28gH6TcXqmtwxPIVxYk4BggT9yGZQibyuJ5okOedlq5T/a0V//bu0H5Db3ZJrwVYB2gtQMWW3qOC623Vk/5erB0/VnqaUZLGxhT28/9Wi+qAGgNoLp2tuHeJkUQIjQU+toZdfN4dNlwZeQrZ1xEtI/nDZvmBg6w7rNxC3ScX90dzMSmMUdnEJRrmCP65Fn19LF2kF4cwjTMJgS6qTH6+lH0YaxBPtTs8WfVLL1yv+HoFpK+A2NLrp/cvkxv9kWtUHJOHuZIgbW05OIii1JTygt0XjtyUVmuITIyi6bmgbPoelsFQU9yEKcgHJeL9V7pkBUweDnxV5sehvo6wNoThYPSFs79TXuNg7CdPhTxGC2e6nOriaAEpEGMqir7rrJM8jnUVus8VluOg5RDyhVIQ1rXBSbq2z1pZ0rymLcc27l3OpTggooipEaDU1cvN0aIAuLPD0XhHpQXjUX7nWz1Ahy+F9T63PZ9DSQmIh32Ad5rvbm+dO4UYWOfSCVExBnkrKPs5cRxG8c6zdBCKg7dd/Y/erp+RC29HG5x710dTk6ijqOpvjnaGSFdr4zg75YkgsvQewNS/mQIG/Tfbn9VG1beVSuxXtQrv/+WRZQ6GljxROacXZS6Qq8y7dunwcWRKyOjrn8wjJsjp/idXnn6B+tEDvWwHgBbi3ehsz9ZnTcKgUo1pI4q4/rfYshOC7tG94bbNwkWWodjCmpmy7fMw45SgltivCMHzfTteVW3UBqqnBA/hm9jGKs1VAS67x26u47gsXZsbxOxNd31BW0faL5+qmlOrdGl30xxpbM1XARSQxdGFtp0JybM/WBj1Asp78XmPXMqdnHAH7asdVOZfX2doLbBij+77Knvy5T4Bz4t0eNvr4wIDKp8KdSemE3fcts6yHZkUIt5IPDRtavZck55H2puVy1IFet69XgQpXWByrNE526whYynfqvTmA+DO9ng7k2GsM0gp/qoPx6GookMbwIIyYWaBzABxt99vogWsfCIsYhgC5V/r2/4lwpM8Lo2A5fXnfN847flW5Jd2T0c8e57Cy1z61SdI5IhHkpubPAV/UB21mZUZCkHNh4h7/asyeZubbJxZDfMvHYE4UJklZaz74geoHmNX/xpfDqdXqLj0wvVZy6ZqbXUXo8RJw02Sp6O9YTBClWGX5Pk8LysK4AgAMv4a+3j3j2QSIGLgrEH3tCkcii/NXM6Lxb6txt+qyDE5k5msigMe5ZJlRZ7jJiXiBnSsnKFxeDvoPdP9lu8JNrF2HJJyHN3IWyIXzvvVV4Azwf9Ed67gKTd4iMS6rx9hFu1NT6Io0i003I6G5uUGqaBeQiKdDXBf1qBP6AjVvm3xSh5a17DfiPYZ3+kly1nKvYBxbzO98jHX6MOdIw48eg13sC9bvD62SDv8epp4kDRJI4RCnbHyAFemmiR+VnuyFss4UIvMIt7JOqkbR2k2e3uCBlSutMBwoNH+K/FSEM4IMgE34jmyvDMZQgilJVq+Fi2nOdq809hQ9NLS+WGJoMMub6zgtde2q9Qm50isnZV05G/MKrMCbrPbPBRvYV/8E/82qZhhAA2nDsxmOtp2dmtz/IWiyB1Loli59tueD309H2/p0icsd8klmK74AL0IHtL7kpqfPwFC+DaTSLZWPoYmWNuIS1ndH7cAOjzIL39UHzUzFPKtA4fQh64r8bpQ4XBfjpNIzwGKwOKLX1fmHiZr1A0tBx2npA4YMc8P27YcbVXuz/c/3xtEIBhJGHgIyypQ46YerL7b7bbxTKb70we6mDp0WV1afJabJFy47utQe9UZ86z3Nvw9/fyJgYilGeJM6sLsgaDxPHiXuksEnqiui6gdeA1heKrfeNBPiNSaM78DmjQiltvgdELrJRVJCWDWdJTZOBCK2rzMzMV7XWSq37ORZ5/TGetcVnt7I8YFJ0+ffbs8tRCFGAs104QL6ieLyHcWttEho+t6ke/2nQ46wRSq4w03Lu1HD5IHI0o9pfGi+BO9Wa63OrWuWVIV9TJPaaEIlP9/2mz1Z45+u9hZ7+BkiC2cL/6rRUFCInq9h535zwUhszXzRe92RmSa79wHFO3gV2s7NiYTqLc2YWnBx5sSzw80Sk0vW4ef8CjOFFvA6dcFzLVtoDKl85SsdMxemvQVvqiCi8+XqyDOucBSKfY79vUle/0wFiWbn9APwL3yF8jw1A5/wqjMuMyqhXqj+Xj5Jg1l3YWQt1QVWCYM87Q9uYt2D4//JQO3YN5/umWVrZKHqpjb3JncdktUy4IiC2bKBc9+4LMm0GExK9giYi3RVOF9KVmWHvqFoPqF1xvu91VHi3sYdgfjGa9kNrsSrsLI/AP633d+Me5jlbcx4DrWftCLXoNyGyAoRwFMQ1WlFRom66vB4kAVnrbMfAYyvlhBf8cFXsk95hawOmbL8xWxRLadxREEhRfiZpFHlO2axu+91tW+cnyevr15bYLRiD+RryZ4usW5XSfx2cKk52+8TbaDVRv+BFlqV9OKNL/TnKCzNV11qGVmA/aB0qA1y8/sDctAhBGDyWNqETLsxV8ZAzkasgHR4IjHV8oxd/aJMkmIbddKq1W4yfO6dkASaxRfIwutlTvjPFiaFpO3IwxGGbgdoTt0r3Bssi4g/zQHFUYM6YyuAeQRvJ7u+9ui4AfhHx89s2EJBWuVjPVq8FSeTxBrvj6vjpQaVsiU9xZr37NKQVuSSEfThTIWC0XUBhctEgGMgXmtwqduPT9+cTAW1fmBmms1ZJpwZlHsA7JoKdaYdrMCXvX1NV6QbB7uwVuGuwfeiMSHvS/rQ33/k3T9O00osymQOFI4sEdxfY9lPoy2lfVCpkggkat3oYM4onZOBTIzhBeBNpEFT5Em/n0tiaPYrra+rK+H7mLLewiWpWe8yMCTw3qo/1viwF0YQGZzprj48OCQ88+OibA/418kxpLRfdEiDhGpseG/avfKDqKPhwlp3RliKhVdmBlVyh3gYQCvc+mFPDrHlW0H13D94/qwWKQILHWZxb+jtwQySjSfZBCIkEtTH0SjP++JNIQm1ObZhI1Z2iRG6329hR8/2UfHStkKS0QbrHIumwJwAPJtha2Xzqay+AaaK2qXhJLnebfi/oRTgt9a6SSQWGU1rZiajN982xS6e+wQItm/M2fwwexCZhEfkPJhXY53P+hTvycMuXR06Zg2yimqf0TPi8FhPcRnruwuIxzPVGQjPq9sLY9X7T1Nx6c9js3SkaKkv9u6n/V/6xeElzDvHIjk/kD9yNvDKxe75ij/SyZ4fek83WHel2DI0AMgZHp/wsX26zqyJRdPOpk9Dxb9Jjozv5y4XRvK4h8SZmDtoNPDOua4/k/7DmymLxcDZyLPFUEnz0wvwGSFFv7sbSMbRI5a1UhfXqBsnV8eqbX7KAQ5ubbLxvJwezbdvLXC70RvKqFrjSBSa3mBKmSmqtb6xvW8RoKMAsKY9A8jOThlZFkKYVFF1Wr0zLDLHNUCrmXCEL8aFU6XZ3XOron1+JyhyZIa1hHPU9aVsv78ELRQK/gGOuzHKDlDM/m9kBbw7nU6+McuVJBNzI8nl1umkbGW+D+0+f/YDdm1v0/ezBvkjRo1Nqw1Hi45/fOFomEoBvVzWAEv72N6G2bPy8yM1vPxMMf+/WQhd1qDdBB/7+0hCw/jONtO2UP1Q70PVfPU0+T+ceeP/paNcM56si/+2d6YYxEAblzSzCo+zDy+rGCQ6BdR/D9gwgVQaZhEqGVmvVKjh39KRacJU7gOkRabED/vSFb9UOuDX5QaYGaSeEHDPUAGTxaUXFClGciPZhiTcFmrp2MELTG0lwr/dKIDrIpv3CcrXS6bIGW77HY3slkpXO3uJG1hMADREzZ8w5NT6WjbmlknWf34A9zwriMC98t1hskE+yYVhMYwCeNGZHzmzdW2dlfh5o1yeW4wTFXCOlXlRQ8bO/4kLOOkj7+jQ5aJqqhIIQNnZYGazM9pnvWoaSWIwb5fLMTkt/I6z5k6Pb5LctSii9UDio2oXo9EscCqDWt0XfDnHtyHfT9oNw6bAs97uOGi637WweGPHuwFklP3qIve5H1wf6h8bkcWSCa0Pnni++e5vEovHq6fN/OF0o5yAfZpUT9KNKOGF/10ClzNzKqemo0oTFncCYXkxSCKr6UGH/KTIj4Jmrhl5kHnK0yET9nHvFqsS6Q2np/BtuvRb4xL3pG4uXsOKGCbG7IXod7PM8vpbXcRhhnpr63zTP/d5nJeOMAJVnvrXWcdECkK6jCFN7ga9VbFZ3PsGIxHF/SH4fx5XDKCvQLylFWGljm6kKXk75qrdrk38A0KN+aWcot/lI9BD6RZcLPvN8C1kOQNFQGOd7h+S+BTwA25RCm4wPuj/V6QGenrqH+Gbyf0bPCC96GG3hH7Babnp5Pla5nnb2Hcdo2h2BxwQJCOcw55NF43/nW+YZZipjBe1p2CvvLEFTo7ZQp8vdfgcufnVcn1MufGJdBL6RCpTvftNhGPi21PJ5U9mYIoLPc6DHNc+zeSSbG75vWouMS/y9jW0EuN9Z679N2C6qQ3jGPk8qWS6XJIC4+Q6ReN8rzeAms+d6m4KCjYrkjYv7OwflaqTSXlg1GebETrCiB1nPUNbL8pqyEGA/p6B5FdKAXvHjlE8iqI1bpHuyBH6/wH5gTOBnudDVUyiaX8EEXNsMy73kzgK5XBwfx8JIO9e5v+rl8XkbEp3w8u+nCXTeEjkvcj5reWVKtnwVGC4r+rUdkaVWlGAIwezeR9rz3Mp2WdPwbAs80zh9MpopoXPBRIkTz+8VxHMz3Q3R9oSC/JbX75Hsf5lNOu4gRH60K/gP84dIhQZXPFe4HZKw31IT4nJTRWqFePNlt8od0Ben2+E0O8XWuAloLYCJ/gzvKByBQeCBKnj+upWjs20wUNvc/pTvBg2TLwKgmgEz4idhcVQ75Gh8I6+zHJzJYb5CL0Il8RWrb2dOt3G5XqpbVHXjl8jfIXXg35JOdAD3GGz1ax2Ksv1oiyF7hkSNJ6YX6C76XN1nlGQQ08YzxL6lII1GFfek3fw0WW+pWRnsXf8d74OR4sNh1xEAuo4ej9+9cZG516EOdZukfs0MyI95Glp0Tst6h6X+1jsZdeezuuwsm/rzgyEtuVbFu5nJXvgZXzTu4PgfseKIp2yodyKJBBZNoPy2ATxjgzwOu/b8CERgYDjdkmBDgB4Hkuc7yb8OuEtypQpDLDsLo8NujR6v5njLqvcIl+dUFHg0ZgHmeh1V4H5rIHg4VhKmlb6DkG6QhFXtzvAxSLJXD5Ur9SoTz+vGlKV5nB90uu9Y7hERhtzOfL4Bli3XKZmNsjnKwjH9AXZojF3sZpvXzhWY8CbJF12VumKMBUawYi4ckIjFZPsaLLDBSkUzLp0ZGHN7QWjIPQlr8Bzo0djklQi6+cEBHadH+Wx6n96ooLQhxAvWBUpPFr/vngtJC8v74yWZiuGUNQ1CUUaaITI5nRQ9FKmbtY2FWP6NyAdg/paP7OJl8F8yhKMSIe9oV+OQlkhxpnmttihiCrXr6t4ZSz8QioVx1IgJ03m3TCIieYz9/nnWcVRtysbORnwHnx6gJWdmT0TQGxMAcoDH9hx2OKI8Kh74CNYMWhBtxCef7dIo0tWq4XOuezxad13n4nXenuaGDtExcE71+plvnDg4o1gkJyeYDW/FluFN31UMrlT4NlaZYgajvyPhpnsLy/An4xqZx5+2sk/uCcB+ZVpHVYs3ie7K+T+5kQRbtDufU8g/NysBXjUueMkoP968RlcQnROP3aIUf7KalkMsen9u1T4xDHKqawsVlOKKmnWlEx80EGH9xf8fVl0a339iwjUSGKz+f+TvIAWMYkXgF1GMnbwfbY/9CI88OnmEK69Wh769Gfszo83hzOIpUS3v0VHoJmuU5LMBeeqTbmsgAwQrohqLWJWcfvL1p72fkO0p5vBKk+DTjA6YumyWoPqEw337KZxcZOgY4Umx9S+mWB76zeFMwzCtYq5SB7eaIX1VL77+SW2xYZYUuLl6ycZc9CdT2mnzngHiAOok05XTI1Pw7N/GEe1lg2D/jSh7AOj4rn9TXq2G6uWMPr8bZMB+wFaFuyh1U+S8M0CkfzbyT1l6YqQsmGlKV8l1sXqXtK0g/55zPm7T+khWJPHmUBjcNP1XM0UG267cMlpY8UaAu3AW46x/n+uirzcN499oDDWAbWbdrd5iPO7AtpWfVtMSVzE3hsVy0ODuCxVavHhUlP/y45F08skR+iI0Kd0v4AXfS1CSDCVLfoBI2eQgZUoqfSt7Z+NdTMgnghcYzKrk36kUKCIChHJ2GSpoig/taRXDFlaYdZoYWIQesEUt3DFeG7n0PKesDPTsAmFnggtPKxwvczUUynNh7GJe2O8eVoflV63B1X0oO4fyuFf1i2vnEuR8VlUtcg4Ac6bPCDMprSYwOfA4z2uNtZ8JUynKTjzCCggEPbj++Udt7XwSpo2g5nYL/vuxi7soF3fbCFV/FIFm0q9nRfHHe42coYYO2A+/hlZJ37vsNJi2UXI1/KJFEbO+ib8vlJUDWqvXLGS6lDUbqQSeT1woVCL9QZGl6p60pbbZuhKPVyfNbX7E/oB6F/vV8j3PjORp+t83xLsF1cp06CUVVFnn/nYL912rMH0gKvjVjo4fX9lywtzNk0Cy4pZAegprrJsJjanXPpF+6Xfonf/B9fxmX4CiHPeXJ44HEPY2oELNoBjjpY1I6CHuHi3rlotftFPt6u6/40I2dgJSBT1Rb6DtgoJb/E6aQM0BVK3YXPKhuj/kQguTxnROHrzPBdFzNGrFHcWMNlCIHP3/a9tfxiJSEgm0uOAlxfzFLpCNTtm/buqaN+8TdN9Y81oh33ESA36FBie1jvccBEt57beO+61awPHPKcACK4/WWo61HOOSKlYBXEQuAGqp84ZOB+r/GL7Zh5JIIBT3Ok+OVS1x5EkUbcAMGanQfH2KMkXfU/BYN3t2kBvWEveA4pEph6zZfenaxL98zsBcj868DpXqwJnYXH84EHY3pX4yRL2KRS9csTJTZcLrFcEawN/YbgomFnjmaL2xMJo20cMdt/kIvf67O6IvCnAmjg4EoQAA+ruElegoeHTRog+HsmSfHLxorAxhKE4EeW+nMN7c09bKiYbQ/bcYaNIziak4Ezz22bY6c/HhT4jnKvB3jdQnXZCzFClcySC6kt6IQ2QRvmZm/PqDcNKIpup7UkovJuRfPSLnHe6aL99ZBsytZ45Xu7PfcG2crtB6GXjPzQ5eldj3z033xC/ywJroM+SsfAteJlgNdvLOmE7iuSDxOyDxSgaFRDKrJNKV9SemdJvaNrigJ4Eh8qWImG5gz4If1a4ef7VXC0KPS2Rcxs0VAESFSszkZ/gCQWqKSs4ZfuTzdpZoMLztmfZmiwHYNifu8SA/1RrbvTvruuI7MbOrR8ICKBVV8ojmYBrKOo5iMIpEJu7yT/aHXJ/HrXpkkfKyUCiwdD8WOPZWKnR+Y/6Yd/WR1Hhq4ThXsXBjej7bLPYiART+Kre4ebCBbInTKzqIZh3SYyGEJ1CNx7fAaXhsqmKckGZfvNGiEGYFJeeEIDcfZ5tRgvCLdE8bgHfj7HZ3nf/jEUd/xAv+Q7D9h78mAows8SIjr9eTYQgFFsLsz57/gk09Yh/QiY79YKl+kqt0Aa0/JCkt0HvhgmBK/iiavV6D+2/y4H/15rPoxbazbqCkHBZT1Y4Ck27VG4TOBkKl+Ugfvza81RKejog7mxmeixF22IA+j9T7hJlrIna5/QhmczPb8gED3ny6dXuR7dNFI+DsyNg9gHCyQenPMzCC4cw9rV6sdzRxdUbnTb+uEiVdCE76rGgjkt3lpUDxZ6+wpMJM2YXiqG8PwW1yDM4D7itM9+vqKrqfQ/3uz//s7/uj6SEMkDc/HLiGypEGSzyPLCsmf+xhPZG7495avPjODW9wfEb15McuE1o5UaCP+DcezAoh6J06XCs8FmRrbkmYhEXiRfznodEfVzkWA+BMcvaW5v38BLnP8sxiSrmyPU5u4s9+ACzxNOydrhZwZwoPMWXQ6BUN0x1ximHB44ZAb41dl+TGOUJzm902AXy33xzkWZoMx48SiFaaA/iz9tWGLUUHr6qsrhr96b989ykdZ/2NIXq7N6hdYg2+sLpzLZ2B/23ao/7v+sokeb6omw+M8n6UlxWH9h0a6D8Zm9WwjHACJ471L5FanYH/VgjMO+jm9uAiS8Zrj+w2VAJgqn254s5En2bmJ/orjm432s0WkVGFi0WXjb5TrB7/HbLruEUC7jvDjxAGiKds5HGelETUUoOEvlZWF3LHgyG85lMkZnSJBZ2gJHe3rR9C3ck8TAv0cYoHF34lRuAg9psmhiZawoBB3lgiUvnp9Sgs6GQenJDUQSxidu9CehgZP+wsyix3DoF9Xbiv8Rf+aL+h5/M9Fnw11A60tb5bdwRIxFufx14pslvf92Qi9sW9QD6j7KrfK0xVIT2xlWp6CuvXpGrFGCuJRlaaTXLpqKw+OC2KYIXfvFU+BMnkHiRdafM+nKr4coO4hwsnZBS8nHiM8BkYz+lb/L48bDcfyZAf56GxhH3+PSwXIQ+PHm7re5mI5kbnNvQtg/TpQsy5sGVJ7E7Fq40u/XVjqPTVwk2n9b9Nv8+zmsnHJ4Zk+LS5Q3HE/gg/IpmexTpnAO/sBaXX8P95zNcwcJRLOrA6s950lBBz/exCIeSuxBdYhjFctC2H1sHV1Cxksx6qTOhpMAlj+oLeh5GewfPWpXky055s79PFIBYuhgKXnScBTQEfvPkyrmE3TB2EAvLJl4oiwEZlzxBQa20sqwhHhHD4IVH/l41wEPn+DvWvf1cBjDXNjrDV5JWf1Ef5nu0SHpiZ9htPJV7m8X6dDr3ft+2gvxFP5QFyILof2Y0I304+PUHAtSNUn5qKVsne4DDxTz8aErvoOj1tVx15kddtALMB3ngsyPrHDYNycJD+Bq9wNmax8U9w89+MI5sSbJOAepMTxWtqgSNUXMuPA6NGbCcsyDISuNelb/IepkQBa414rCxnFZgSIZ3i/xvSSIO1pEc38/GpLHNosR4czR4xe+TR1gB4E7ahaGmXjwyvalv2jTngfw9B4aLzi3YZIo8Nvqm1ycjmXf4iYAXMX3u1Q7l/03HyCiusYUSG1iLA8plfupnHsFDB/mbQs81WWXLtXeMxD+n07dbGqXmE2z4dT0//yeeUjX+mkX+f5I3ON3YKeUGC4kG8mk58gPnXoiHA6cluUSpbqMfJkOgJ1MWOs6kyHkvizhLT/kiFXBkufUuosy28yt6oNffYvrV23kGYT7BHVN4I7z80heb+fvFoy1TBDj/b+UvVeDq1quNfprzjs5PBowJplgMm/kHE3+9ZdJ1dqnd/fp7u++7LCWXWUzNaUhaWhI0n05AXWef3iNzPkgQvFS39N/ndejlkzBbKfAYiQi9ogRgRrB49Tc/OlEudNqpL2ep32DveUTLMBjtJHHWZjZ2ONK9lbqOmps31SpoT8ylNvyD18m/N6N6xwEDQykVDHzjf/DnQDlQeCrpvDnbg6T/Tf/6eU12ArsdWOW/KkTNIYhZ+7LsRC9CT+fj2mRT/OxMOjjeu0bSXizBolC+Zw5+rOvLLAJ6dg//ZXnQNdnqqZCWsL2K//Uqli6t1jW2jHq+hx6t2EdN8BytePU+NmMvTnAZNe7OYiErWPtygU6DibVm7bCcAw3DmHVaJn/dfUCknkOSyO1PaDuXPURcKu29QoVwLu+nWyVRuiJjd1aj407RfJKC+OLBzYnG1UvLIpuTn/gHt99h/L7aP9jt/fC5DzX69YPgLboB5H8S8XF7XPgQ17dKGm/PuTxAO+QHK5niTl+3FXTlal32xzujqQhAP+v+GtGNg2u2/9rd5UtYjzB95j2qgyQ2sxovQLXB/1R5ss+ORo1B3ZPAC2Z+UFIGp8JK5iRxtQF/HkXg7t887W7b3I6BVovlluBb3hFk8+Ttwnan1XOR4a5zFR3dr+oNQKlI/TKMoLZrSOG+NECh3TQ1Yrw4AuFjgRc/foZyhV+S39VXxk360F9FRjmQaerLd8kqOzEjYYkKybfUsGBaVpNaWoOiOLVm9WcYML8oP4fEONH/hCckXo56ON5Egyokv/I9gHO7XFlMbubyp8/P+XJSaxePSlHFDUaQby+//x0BBkQ3sKpKf+hC9UDWIFBS+wQAE3ZWPZ+sY/ScITrs3+k+vB9dTFEW2WLDUsUnmQNrwbTs+nr1iHd4svif8DJ5pT4qMTYZcUtnb3OX50M/bHFGsfKKHk9W8TCfhQRZ/33XdksZJnStiBvf4GdQa53elMDW6t4HbDAMVhD43hgb0CJ4K7gTwxEJJI4csOBvysxGX0utWjHm52P+PVX0EveDoUnKE351cFbvq5ECt2VRXtvxFNBrY2YZxoH7QHmdXk2jmfFOwf9L/lMHvQFsPZgGs3in+qQ8/W+K24roGf450RNpmdY24dDx7cO8ZtIr8r69Hc3VzAAAuLWwV14duf+IUuaQJYEjPi3c7h6k5yg4BZc5gjJjsKj986s1fM8ZH5pbP+JuI4DkxtV+EqVjYo1lf0Sv+KAIGT5ZnDiBP0ulwVlIFArktOu0RyD9eg6fCPBO9GQqjqJY1GcCYaCXWse2TKXkAQiZQU2PYF7dU4wQet39LQAC/YkCFWoEAKx3P0U426b+ZZlYZbeabWdebf8utN7SGTl/ylPYkHb1KiGUHgTzt//jjddEIGOVe6SP127J5Myz34QBHSek5RVJ8gWoX89u8ZMQT8dj68M591Otsy81bKnyQusdmbXws0Vb8uP7EglLmd6tflBTqekPzelxvR7pEKRroM6St9jqY4SFJKUuw9lPTzCUc0WOx6PTlIe+8Dt85GrLUrigOrLCFyBaVRsjgnWfq6TUyrjgx0oWlVctKajo3VvIfoiAkfQWoM9v6vVQ7EeAhRJ0guqgk5o/GPNoCS6XiCK1BNIZY26Iedafhy6hTW2gXWA4zSfMnzufj0y5qS6q+tK0pXJ80nDFDYpgUzi1uGaSX5Qr/9AaHcHLHBM+Q7bwumh8JwUxfkiyZXFTQFKziiYo7UGiIqfI0oU3+i5H4w2p0frBLRyZT3gcwYJqLLZE8d8kZM7PbN/UD4VvkVwxYZxWoQfb1jA7Rn0HuhmR3eyg2Xr71xLqpDaf2C9/N0+mPwKNvJlfKMh/f118vysHxhS9uXzr3tpmp+QsbGakOzVYU82+WDws3YzWIvMv/98AsSaRyEJ8PLllMsRcKRa155TYYBLYsoPnz7LEd9LoFdwGHLyuNzjTlNPAe4ldnBeeU0HFBGHvqR19vdN1K/SoBxAM+Evv3nleJClmVgJ4mIjVsaEfFcqfeWG//4qJQgsqCI1JeVI3IUOiNnY4sUSZZA6PEtjANucVOwbqq89c8xY8nisHDmxTvSoSqofP8sg8ltG6tmWk8VTGlRuG4p1RzfQhFc5uI2kyf2TqsKRvmCIMFlQB0jhAaLk0aGBUoDmXvHpE1PTZraLYUqRAyYFmRaVG7YpqMWSoDTLd7G2glp6a6jV+9d3JXfxIYMvgKRgdOFyD5mOVQNCMnlrBfcDCDHo3uEg2+P/4tr/UkOSH/KN267n2ff931/XQi3wwaeR7/8QCddHlr5tYjAJtDnSsfX9Iv4XW/qpr7NQ3LjDx7NeKOgTohEhmpTdPcb0kYKdjnYCVchb7yboeJjP65sPRiw90rCCINWyA1F5LC/OuJD/wQm/gKqDFaJfp4NZhuLguWIoN0pDUAATwv4tXF5FXMh4ceU9BNzPZ8XLhDYwSeOhgPaQaW5gUnU6xUUQys0jee9pi1tPiRdCwFkd1A+rONnT9O008vPYHL4rermh2Y+0CcSITO8UH1FC+GtVXyLTQSw3J3hBz3wDSmq8/00BKWJUXpBZ+1MGeK752ucufE85kaiJfyT7Q9xU6EyqG/XkB0aR5HIPvl/LNLnqa6RXtnxSAtUTY4yi7ipgl1/bti3szEWrPOr/x03WfpC3lo26+/fXKUA7B2sLarXrv6pso1VzrO0iDScIK8jNpG14dgrz+D/saLHLmsWwRqp0xiMPdO3A9obIcTZMBnEzp0xO70vs5Sj7s2Ik6fjq7fUiMq/20JzL4fSvUEPQYNqUCT51nHxoeBzYnaZpSXCCdHUn50k0IKcgBGtGTk2s36rRVkQrcDulAbJ9ye3eMWt9/h3dmhNTmCUkyXQeq3f51rmB6nCjgrF/85DyuhWi9752lVPWxl0SQlyyYXt/RIjbDx2H08gsiYTcGe2VXIJtvk1HducADQFIPmLN1VPQoPAkG4HdJZlqytKimHdSdwBKS/fMPC1H4dHF5bIr3GD4UkkiOsrPq49IpWk9JMBSIQ5s0TNFIam3uaTR5y2PFWX211O22e0ldQcdX5Z/vfADp6s3Im7YqPp/r/9KP1kme/iyYYDD5x7+FdDQE//mW2PbRsO6xbfzXU2ma655XOis9AxqOXKihTzV/8PsKi/T5crj1b+LiIDk0Zz90vyMX7OSh/j/tDLh8Yk1fboZUkxwAS1OlJpnUWKgzZtN0wRfaecn5oheenaFbC1t8fFrJdNl7e6sdKVxQoIM2ksj5gqAk+jiOM7xoGwuSTjtd49ZsHCI4KPL531GR9q/o4V+pQ8M2cSMUagXEeiEmOjUnBJ6XkE8dTMKCdW0k/ru7RVCT/7Up58OoghYqUPzlQua37TzZCrN1BYUTN+p1lw3u8f8dyjdR3pHWBpGUbzJncWbNF4zYl7/2dwwjtDhKendGUTj8Ax7xQKEiqEHrkB41xwpJlpnih/Z2pOfncKOXwuCs4/p3iCLkvrpG9Fk7H3Z4OkqzpVQokAHpyc6XrOg7AzaU6r6btGyIEE4XIFwiESg6wOIcBqm1+cACREEgQQcEV9lEiUsaX2J6aa+Q+yQg/zK2nk0s9PuzmzhBscNM/ivvYXGVA2C2agz9Kx8eD/v1/S2CT0VICZpPCY8tPmkqkVbfnxv3wgm3iuu6M8R/ogCYHZzyDgnW2fhuC+cPqQqls+ICEhoMEVUzKgW7+7DEwIdKQgDS5J/fvtsz496q+CtjtcIPc3ucaWgq3EMuKhba2MA1GQ2joU3g5zBRyKKpqpL8QP78116LgcEC4TIbO+7TPkWwgXgVfWvB/DEZP2NLjh4OSAr+CKDaSogWr3mD23LkjEEumeHCIaMid7tRuHbKwt5OR0XLy1ZOnOPh/j615CGrV2/1gsu2e7PDE8KIKnTcnYqXNjtKV5Z2kda0GkfIJNMPWVGooB72Cm8RcS6ZwLxxWFM5daxjSuZp1JdaAnELZ5reXtfELRU04x5qzElqxUai3fQ9rZkwGIYs2oDKhKI4OARBdm47ADrSV1H5XXsZzaEjHpMR68YK2JnFqeDPo5evmbS6q2oXW9jdddGuU8PHa9TIojvWWlUdiHwuB5S6yiULdk9YtJP9AdJMnP0sk17mhbgoSRZYf1EQy+AhWQ6t1xJ1Jkxo0Yw5d3zmld8rQsF+mFYRD/TA8waFo7drFVNJhDJEHEJD5dbF5IVdhF1hjOoaSaqT5fD/p59Jj4XkbYBxZLpH/9hTuLC5IyxZFcSf8yOSH+U/DPm2MNdEwvLY1/G+u/NSRGUW8EPBydGv8KJr5BwAgjqlToCUNmwjuj7SQKcibcackYgR8JIAZs9hbqZpZ8JseeDABUB4koM/nRz21LLy0glZFXgSuOb0akk1NsEf+XPpJSRQ9vA1xmazQKOh+O61DRbscbPxxCtV/qWlfj3H6cwjBegfzMriQCSJveW/Z+KQs1u7P4xtaNYBrIXDnYZGKPAJLaB7VEvypt7rRQ5HrqK0LlLWufYOHKMjqNC4W9X1Dw2TehyG2cM23MgoLfLbATofSigybN41d4PbrXZ5WrRRwvENZkh6vgiPKNiFE0cBc3hI1jK4gdVBfWbgr5a+fHXKxONughtngnAgDPh4FLioj0kv1wgXCCwhRuFDBw3q+5ckGlwzOP96XylK725SIzXyEJJdwZDkzrh19X4NuLoEWbzaBPR6ELTD9O2RVEvxmpFLqBAnCfJLXzash+vXpqhAMVYeoVFfrA1VEsGlt1Tkmc9rhy1+ojUEuXV69bzzAZ0UMAGP8Zen0v/H1EyU96spQvjMbaWT3knbkT14k4jHYkHn3agaX3LmDe3wG6bow8oQozmdLj3O8ygshRjy8xYzQCn92UKWcPrUkx+rdeWjKfnpn7JipBi6U4pyw1DJybnEy4VukV5XQYPgiZHDBvaIKEcYnqGJ9ZOu/Jq1Zvj2wKlx/TwYDSk1royYe9FmNzPPAfGKrX5BiFpejsCtgh11viOhRqPtr2AOfZULhfoyqypUGhXXSio3jzzysM2k5WM0sNQJZhduqRTNIYQOT7uvQG38NO9pSnTNlC4G/H4OVxgpS4TCw94b2Mv31lbqmDxZSFfv26q+rc9GJj4ojJr5O6ZPPSEYScp6HRyQCrW6nLXoYQ8KfPmiPI9L4sQIaTMA4VhEHpK2gSKYtWP3qm6zhNEhGlWTDBJ76NuBu/Wjc4DjQb4wN8ocsaIWkewfXYlnXVdh6VXzJzDVTQbbCyvX+lcwFu9Lqf0UyXTBSEUX/3N5xjG7F5ghMtNRvffu8SNSonGTgyg8oyT5Yor5QTk7nZizQoq0VTK05vQ/k03NeCxkU/vaLJcaSLHzBwwDcb8qa923rSj51vaA+8FClSaDE/wB+3jhlCBCQ+xhbCrYkygJ6eVg765K31Lbk13JrGiv6vJPHcCfrj/qYTgXxqNVmiSwRkpoCt7hSGQ9oJSHLrk0Nip6gQBy7+yTGD8qf66ZzwPwo+QYfyuNHrdnykbB4nXwe9Q5t/x6iyIIAWpQbUXbkgHFEviAPWo1SOxGE113sglDA/cDr93Rn1tq6e+1Sh2BPyrQoVS2bwakMpBrM8A1H9gOnA4a8qtVbqnGlC00xQZRAm8FazrnjbzWGXv+3NG+iEFobfxyGSN9zL7e4kXD2ILPKZK+suxoBth2fUc/JAJ9kM00bWO6yo47Eg9Sz/LSoEJ+H/LmtfKENSxtHs3dGsQLMtwFtJPLzPligMXy5sSXFKBD09hs7P+gYUozWHFG8BXXg1XD6GrTpGLt+equrt+JXphV2f4gtCsTa98cRUfakJlf2CnH4J3PfJHuL4sB7AckURN6URgB4awPUR9vep7f2VGor/fz6PvZvRg5TM+HvTeV6WRalSbirRlks11c4e3+51rdWzMPat6oMLAXOkQvsfI01OlZ5lR9CE5n3SdL+vAZ34B87VXxvGZW+/tsBicjdJmz4G8IzJCUSG3yz5thxv9xGp9EH3UTlURW9p9EuxmgR862B6GD4GXh5VHD+gzV7Tle0JAjwR4iR9tODJI5u6VZ+/A3JbdyDvQMYF/unSwZ+fcr+/NXxuvRrOpU8LGuyXxpsV0i3R/4h1KwsILJj3Aj5N1Q9igfu/5TXTYBVwqnTbmK7ikIJ+dHckAlZIkE2gvoAEjqLLl7amm96opyeA1E+JLSfaiVWhOs5Lw1t+U/jBeVLcUdP9+cZHCvHeU3PitvX5vt6BC9WkBT/lHjo7HK7q+N1zjFK/FqSpAD0u7ksPMPtSgrHILdUzvtw+kvwTW+4IYqwXDrGZ5l88BmoHPkmXfb5WefOGAy76aKDk5C/rpXBmG3py993m5ZWhRDDpQUHnf75h8kffbCN8nmCDV8Lb7JxhMeZR4bOm0n49J2TRnU2y9YUdYb+ZaG6gWkhQKt2MSbusB+hzQoXsCZ/C9wL5DnkvTeI8vWdRINKluRXm8lraJwUjces/RKcJj5s9F6hOne7XMIkc56oHf2js7pkrtsl6Z1zIe+L0Uw2BKE6WXWcEMJab4Y07bLXpJ6d3Bx2FrgK444ZwxEC0eLCMU1BRf5feYMiMKuK4LPtXftwy41a8McG/B9XAK5q+e0PWuP/zwRDfNNzM9onvPJm8kBtIScIE7MnmkCZMvtCT27FSs+ewEicbBsDfQClIky+C7F6pZtln4YFj8Ms2X44w0Sz/y9SkTlyeA6bLUP0qQTqtv3gvA0DJLVamWrsxnmoiK4i3H2vSZp7P09L9BoKfZSV7I2aWAmPMioh6ASZaR/Nt+R9R3vZBw8XAYEc2Ra3d2AD4FEbPcXdBQCpn9UdAC5UREqxjuDnvb1zCyB/4w7PSt61MS3GL6cLY0FfPpo4P1YziVHf0b5nEgS88jA/0EmSzVB1lie2wWwhSfUn3mu6/CXUOhT6siXFxJHI2guVcAdIncK9d+blS4wSwYhH3IKZUueKPRlTDJVfyp9B5Vs5obPVXnjyTYJpC3WEhI8focx57suFkpts9Ok+uPTTmZvrX6THxjb7w1/quqItRdVK7bakrhfIC0Ve8vmKPze+QjjWyTSBbzThfS/jdWvgSY49R2OyG/yRPzX3MVO3wLWA3/V8/IkPZHH0ofEAueuisMTPM28wYOm6n5tl83x48aKkEI6p+5K34Az0CRFD83Hk+qdHMe48KEocofBmnhPYoh1zyrt4Hy8x3RiKfxUL8OgcdlTCip+dghMKMMv5+tPHHT76qQdhFVq3teFg6SLRtl9Nbp55n/MJz2YYBHcWhaKz62v358qaUQqMZGStCMDkRSsGHSMKSwdMdM75Bw9jBz+9ZRX23+UmUc/bbGbOu+kn7CecJBKqP0Y3lIaz4OETnSYbULGj/Qa+pPZv19Io5sSNBJK+tHYjOMjBOESMzriDn4kVNYqEvRZj8FVCJgc1KM5bq6SCS1iQCozuyg9V0Lzi6VFeU4tU/4DEUh2ZcMQ1Wbm1foS1dXJgfCZ4StzlimfWfz8QUbkKQJWS7ZSGeCj13D6lof64SimiDW5BEhyL7VY2sFxGwCh0+Y3Xy38hcVkCT5pknShtP4RWBsGEZyopYQYC1HAUcPJ24bHWD8JqydC4Vjvu4YGroVgEkPw1NfS2yhdERzZJxBmaQ5a1ijsibfQKAt0W35f+BTMtnNp2wAnxiCe1ESkcfe4NTbNemZztijGDJnZXdGRLqOK/ZtEa+skdtflAcegoiWnsYFbceAabZUqbqSIFAFpxe69SJQCFds33pZx8evjK/jWeVQmFOFXQj8XloojQ8QNs7lU705DMHSt+e0XNjBp5zAr16XcALUZMC6OeUNNVWawasxCy6i2WMGhzBvgdMiSz16De9TdDNiye52URK14//82bo4jwpQ+WP6Y8z4ToDiQ3SrqHmXzuPXhjNQ2LYValjPU9DaeHWQhjLcsYHSqMoVxUOaMm6oOgXVF+FHf6C7AKP0yzl4slopTDb9KCGvS8JckI26Ad23IjAQ9GQIhHrNjuwX92hbbeC69cj5FuJfQ3xd6BisE2XuLU67z9y3m6tv6bj1MYMHxchtmsjG94IQFaLIChj9vSL3RNH6Rqrdiewb9MhmLFdx2m6fzpBahn7QBqNkJVby0uph0b5dkJ0XWzZPKV8YLIkKSot1rK/EMR94klxR/1HPrCm3UeCyNbyySXyhO/D4LlDgllvAgfE9/+q7raw/nWmaZQjZeox5XYPGBPO/PAn2Gn/2t6pfmW2TFZa6GHc+rb+qGKIsZN1AtdkZN/5rceaHoCma9R7bAFQaeTS4kvmRDzC4c1SpzwWPRoOXOerSBazyg3ZxSAHOhrOT4Yptf/KOyArqD51QPsBGyiHbtN7yDxlKyCeAibARKnHKs2BNLHNufLg1Q0t/I3FDLCcQIzi7XD4Eyk9IIqBKqcO8rkOw/+9vj87ctZYcZoZklVX3ShfOyfB6wBoiWDcvLAzYgJQploaM131KyzkH2UwDJgj7uPpguL+shV/K+EA5aBNBEKa1lvjkkiY96OocNjxZ8AoCQp2WHIF+m2uBai72aZIr7PB6qjJ4nAnBdfA0jIBSDzN+f/RtWpzPL6fSiYNHXJgQIgjBh7zRO1T56Or6G513FYPvCWg/ZnBZU+Nz19i6asxkbOxKtb9XoGdqq6l22Inca5i+rTWKsv4gmw1mkhNOCk3XrR4y1XE9seg7CgDMMGplcORrPJ8QE7pStb3HaXL3RSgDATCfJ4fJmW6ak5c+/2Hqv8Qnys+9e2QkIaaUl5O8gBTl7lgQGOJi8rLsqCcHjtdXWe5R6JZMjzTPkxyjAzgCkBW1R8bP5BBuWjmN4wBjdVgPcmtoYDcFNr8cL2d9ZhXZFfoXK21HdMII8GZg2D+KduOYtpBqPJ/YQolQHhNP+EGPm8xOtJnWzyQDmpP8FQ4XLy2F+Xy7z1fPeDaDSTB3OTJOShy5dqbAc5mAAJMVcr756reqwDYvXpe4tGOhLJePchAAAAS34ytRiyA5ibejEWlPDyJ4N5Ji2jBEZ2/nXovCgZvo2spOeQkRp6/+pbVApZKZoPm7Wl9NBBXprx33Ve3awBU+TTli3lc64VZHObff8SsxOPAfSrHPEwCsjG8ytOSSQSXMgeO0co0ydidYMTT6EP0hsIq2SAnjEGP7t9NM+h/mqO5RbMh9n3Wt5gfuz3SFZ0KdOxm7FXoTVt9PSFMAQiMR38h7gkQBGezsnvDn0MPy4WY6j8pdT02ht4vY3W6dykcx3roCdixJVdzWN21KqqhGVOQaY+3cwZUGxKDI6IMpNMDhtFeoPMukMsEYEqOInqo8wQPpMQcKAwQ8zaVJnPkKI87b49aRxYjGLWyWyEZkARw5BK82DY85K3qMjxDwu5Rd/aTeUROtFVAF4yDKH8vW3qsogMkM2RffL40eptHaf7Fa/shtGzaHgmhYr2CdpYzlzHs6Ex63fRx3n+PpVolevB7yL5vyE3+NnmGVj7bxlCleJ9V97EGUHvXPfIsR4cCMFCRSu42+PDSlBW8kk73zgoAVwoQFqxEDoq6bKo9TCXfggdVAARLR2JXpN3iTtkHgYAK+PCwGkjMuK2PtdUZvi9Cc0c3eLSPwhM1yMDRNW8NnijdBhCqAXrEuVHjX2WQYK58zmZHhU46HV4xsSgE7TsD4mkaQJKVsWKzNnmTPJOEvteo9HwKsjzO6ZJbqYWkJhYn+xmeCTkkIuytf/vctlFf2ZBvBTvX9s6PfdYiYQR4w8gWlKB0DtZ1Mn1E5vuxNWdCdNfR11TXNoXTEHyQV8BT0kecec/N2sXHXKkRa99XBsfq/zgawA5iaBMaRmbHPg/LOh62GzxWT+a/kXQBs/wBf4ik4lfr9oGRGv4U6uILI+t6ouMSfqJlvprhUeHMUpv6dED1yjWhGmQ9+fdCbjWiem9uB9ju7Zit6ZtHY669E+8CO7hnGYIfgeFnDvBBTSAyAfNPBH+xYDvBm591Zk3NaVoTDAOaQgjcjkxNks7v/TJNfkFhpO4NbLMbs6nN2yGZcN7OgLGaE2VJpsML1ATb2woWyAvzKZZLu3K/7mlDCu+R7+Po75QW9LsQC8oErXYIy1gbVmjhJ0sX7dEIuHF9bcK8boL9I145KbOmwTw7m0dSfme93Fhq/1My3IRJissx9mruKHHOVt9t6aKodL16I9QnJE7KzyVP8RCrhqg8xz+0K8HxDudiZuwLcPTLEhfywF2t6k8Don3HvmoEjXaQnSMOxn8llfnuOYJKhxFzYMTUOkvT2ihWrUO3i6I0//SU+rZxQBh3VAP2o6Cnsj/T9P3+Irae1dgmVq5BlDuYws1jc2IyT09MPURaVXkC7znWHhrts3i/Cl9eC/tbnDGBReI3HEAi20CWwLkj1kVYqIy9yiXtYWJCPJ6ixRQV0Bv0ZaB6X52q9DB1no+dr7OYh1qRBSdGTNRSStf3mOle/DbzKkB4fVdPAzA0twJlz01cV3exsNf8G3L+fF3oSN6n8gZGdGXD9W3xKLGmDt6+ATdXBmnU5kK8LOlmMxQ7tsz9JrWaJWOD4stA8VW2z8BM8Q4p+YJX4W6u75QhvkMs6OpAjZb51dyuZNZQGFPxKxgTn85ak01Yje/CsnABjQL/r0KN7znd+7d8ItY8zC8nG3M3xuxg7RGmFgcVfsgfZP9t7qoR5Nyl3q4vhTTqZD3paDuHHWdYxp2FLOevGzg+a55gHtnrekh59BJPp7PDClhxYmFMFO6rpMTuv3+VGMQgMFVWyk8rO7iRWMuwLL7a0N/rK7qXqAmPv2ffEgbF+svCbpyxN4+fzVxZ+PLKlGDfwHPZ82ZuUyEen6HBkfUq40oo6brQ1nEMNeeJwv0KgMnhshlFN0bv4S1XnOjPZMCkAbS6UGEg8mdbDYrzv3yE87wNUvvThJqoMW1g+LYnOZcyKKvl+Shit7WN+lgB3VQ/KHwkg/WNOz9dEuuFs0AZ41KtYhCANeipA0kzOjawFMSN8KJ4ZKlfkbOzLEr+UcD8VMLc/W3uYZh1bGHPx59Dmbjw+ch9qQvYZqNErYI3+0u98iafP8EjUqWGMdAI/A6zYinwmhc+PcUVw8myB8p7dGg3YrtTU2y46SKr1kUBSVJy4xTKN28zHyPK2mEqBPfubftDLO7ZoE4aKdAz2VAUQNgoH3G7/hWXr9TfDq+uWbK61PM4tyAOdvZUwPQzmSnlv6lrivfhyNMr0kmx1MoFYQv54Nk/DMYNC1ghMnClr6KEjDsSmhKNgbW2V/sbBhVbSobkJ3pja3XpserciJ3e8ial3+cQr22ZBIrF2Rdil+DWwD9aMHeEkWvEsccWx08gZjHhx8zGZJ2/LEPhFRFd4Rj6lseMX4qdCmdI1FxotT/okr2PQQFC64FE32UhY7i+WTEby3XuX50C5cd2112XlX1MaGZBjXK6fhYJf1bq5M0NnV0jHzOjrboJGvhRrwnTiX1hEuHlGKsNqs6nzqdIQA35uwGZkhpur0OWP68jeoFPY3ny6oF0G9ncyiX2w6Vp9OBouy1KEIRMfatJy9Mk+W+jhfsFgKs/tjCjlQtLf0plm0tNS0EJ1857S5IEn6WBrQOdAr7oO9HJkb3qCYVM/iiBj6QBxlkQckLSBLwN2PPPZvSAnEZeVG1L0Gxnv72h7xR+1VTl/g/51k/LaCOK2cvluD82/Q7W6LyZKc9GM9Onx1dOCLsbfmStZUfDBMdT0xYGuUvoEucDN+uq0zNt0UxBQvL8Sk3rnzSub+qq08A055zNRat/9dPsypvAv4IjQ2hfUI5kEJCU+XRkATG4qM8Y0JazLC0XrctTbCCNmkG9l7+salI4Dky0Kfnc5LVcq3alzx1w3djSld6yK1apOlqdRzAQTOJ8kbv0YIVEwfnLfuHMR9lOEuDMisyyd6cyegOOj7pibL6TzDJHk88iqrY+AAnX27M0DnLAwW51ZZJkqAv3j/8S5Y5m0BCxoK9W3FyRssiOaHOP9RtPV0VMA0L/JuuCSAhPU9znhrmi93EUpAG+4B5ZbNjh/xZpQy4K9hGNebqhXuR6UISchcQo55QPWujQhbmvZOKIqldzeGAhgTmN/RdmAnWhALNEwA1fWZKrD+edyPS1XJsZhFx5Noqemuc86oTfJC0nGlXGsKno76ynlu1qRTeDmKxQSy5iRO6apDyp6NlWwAiOAtDS4LuN0z2nIEfXcS8KmI8OcEoJMDuqY0DwDH1EX4W9U6gxMAo5Y8sMpTtbRaH6fnpHn9UDbGtpK79BD13mOTWnlROmzvxSqpH6wipI/p4agQxASQZP97crkCTaLMWAmFoIG8n1FOGjH3VQdVLPxFdQDkcaMf7dmCcQtPqdSOeS/2btnnaYZEbdRZ9EE/S6+KQxCM7wtB6h5q/acmSLIjh7AzG/e6VYgcc398uzmO9SI0OMwsJv/VuVdn9qvGxyc/tfUOhz7YnLXzc2DcYw6zjwp6uwKPo/H3Oucgah9cOIUETjgZt3Z7jPo3MVYDpiRz49SCrdUuS5slCZwwejXFG+RWL4mBNModBbmS72KrYn0WdDbN98sst7090pvHLEiPLr4hBcyEDhqmyY+rKv1PcAMif/nyQvGYEHUnQNWocUXyxtdHoatVCxkHTAGdnC4xkmG90UrqFr/tGH4bJ236fEJhMtTQqefTheey7gf3dfGemV7+67dHo2a5AE358u2Xu+jzbXuG7rY6m/g+e5r4K61adoDNBLcLq/x8ZrS4HKVWFqg3XZcuBKnJ++J02587C/hZenZLcFSwRbElx5frwlg1VsrZM+0cHnkjVLiGEwvwE8wLjajNKjeQMWP4gqrGmzABM5oriBlhTFJUcoqwV9XOsvkTTj78ttrZdx9F/z0te1zCve2/dGNtCX7Sb+UJ3BLlhm2wsFMKkIm/ZatJ0ZIGAmUZMfXLZEx5BRZH/77/UL6y4d/yiUOI3e9FX/SrqHiwNPxN9pqV9aEZc4Ip8IVzYnWgxNoW5ro+VwRer8u51lmehQEMLCV5ZUWEU8MX7p78+CqpeGH1YJR4oQbzQrm/Z276dX8Oe8L7qtv9usEL1i8wu3URJct2zRxFOnWgCWJ/22inh8l7LJ6dSBrfeDuiVOxVMROlTxsg9oi7QrrC09oFy+CwL9fNYKNihE9g9MMtHTe5Te0rA/AgLl+lgmmCNx6JBDR/8Eifm9Ab+3lLZsNWjzFkzQN+W3att3MicL379x57O3ryd1oj4UMSP70qPv2FGeyJ64JJ3tn7ZsMkKRXoPOyIuT3N1F0SYhkt2qimQQhZ5+4ZOIqd4DwiCr3aDsoZTWEcIbzqwUsJd7qocgPPcEoCuVnz3tIDn2XtuCzMU8S+lStX5pApOR6dFQhGDEGwB+OzApWqaAqbRfG8y+Vcv2BKs1JBg17HjtRzXzakof3/A+zuH9Njj1EwBaCIXGBQOgOrz+RbPNDjjvDXtAZGxFotiKMdvZnH7V17AFmxLQZlYJ/6R+UJ97zs9wV/sGoIbjtgWnnCr6Z9u5XvKq677S3QI/sZR2augu/qguG+pVItT33I+BNgs2UcI+XRLkiWn3skgxJzO481rheWjS6QPGNfCHi+K17g2SfggF9Vsk3Xy+mKEvZdUVIdwYD7hc+HiQEHXHpC5x6G4skSW5WukbfV0qmq7t484dCIj6vU8kRwLl6V1RP6saNCxZkMc3hmCWEP6sPFgRfJAhc76X3+xeN8v1mT8pkkn7de4NSJmzNlbHsx+NfNfdsxedvpg8ET3y5i0sV+Gt34JF/JKqNFP3w7/PGP97zeYUA4QFxRnmzGqyPjP3haFaZhsXY51AFBai139sD39BOIL5hRY0tv5r6uYS0fji+9EkZCit+bEYECowm9LWRP5+zEC/c/VHs461GLiTvpGI23PKrdSkKB3BkVd+DrOKbQsZaTzIP1I9dZpQueBc+MVyO5G4dqubEm4rofqcrkRhzbvZqs0o5olpOIx9Y47WtE93F1M3sdsQmrty9KY3mumpu0bOc2p3VZx/f2M95Z5YlS1/XhNl4l7mjkIcKhTdMBqXYbzmofaJdkTEEh4gjyvic/qvG+r+bauANDiBXOGEUmHn+7XW9DBRgCKQxuT+z4XltWLFNVM8Os1+po1kaWDT0MwEBf2w21e4VO3czWm++8jSR+Pioxya0ISxdLPEZvfErpIuHZlJp9v6eXzd4troMoFUXzu7KAVX5HrUGBr4g3ORQgCFlgaayTXmnVJeum61CeaVD8I/dOs6n0dETe+tgxwmNKKBu39x4ULCAFr9+JS3wF82lD+ou9JW9TDilWMU3eh06KbCmnj9CelYc2PfReva3EGhm/Ing02ZBCAWqb+Zvj/oPb9fZDui1XT7k8WZQa6XcsJ5JXIjsh3v8pX/7fz5xU8xVTLn3LaGtN1Ut35YJ8y9zzBzYMgNbXAQ/hV/FCvFFse1SVvy5huzQQVd4/EgF2VYB9iPCJI9p/vgWgNsPu1Ik6aad8mZxKz+/GtDGwY3BhiXAc78nMQWYmG1k1B6lFXrW178CcCwvXrS+4XEPvqeUREXk/ejbmj+akkxmFTtWm4pDozXtWM0fnTj9ZuJWTH2CiVvIhK/Uvdpbjq/y2izXbzONmby4yrOUTKo7TDnXZ7lFz/z5t4ro42dq3gBdlBbd/DnR9ZsZ+XaBlYTmUNucJ2/RY94FYgyn0Mz/vd2/ihcYIH4f/6gR/Tu1/Xg9+9PzR8uQ/ncCvIIeBdYIjQGu4JYDQOo8EriSnoXQ8Ni4fd1dQ/XWYnQ/u8DaDNcqOhX7RiugVs1Bk9EKPHIBNGRSh3umvjCwhAVDqIqGPNnP+2hA0ep6hi55QWmMEk+Pj5pqx7ANYJ1Yv/xlwg6X5YsYM+mXHQ/2pFXzA1I5L6CANksqyT98SzzZ4LfbKDXzvjXCL8cQN8qRSeLC0YX3gNwMZv9xcvD6Uo8cpLQsYtrNgOMOzc0wvZyB5iQDpXHMK6BXLA8O86PqJyrQzwX0/e2Jpi3qW/x7j8KxtoVx/zQXBRsPEC5R6N0iz/t83/ETWy7nHHBWGoZ5KlG9dN/ZUukP2xS1R8H08vb0hnYuFfaFM8oVzKolWtld75KOBfUTRQR4UNs/lx8miJdumYO9uGt3ob0h+rmpK0qefn9HDiL2vJWmkzTyHO25ahmwh3XYgJ7S4X5yxQa6OK40/lWPxcjkRoKoT1oQhoVqPJW4x0NgojCZ9ivbwOwUx19AkZ7nXfw5R6MuIORPL8JscK2unUcWxirmlmCKpffCf1CHkTc2exMMLM1dPDQhQa5U9on2M8VgkwAlORG4GbrcAf0qIh0d6XA8iTf4ytESd4w501OV1v2QUyh9GXpQIrKhb2Wmf6fg5AJtHEqKzEAZ27eXqcbx+ZfJo1d+gQsSCtO+Jn58GPNot6LPF210woWVqnl8DxPomeSSkCJQOu0J+zPPJjeXxaEvGj7Zd90AbT8KKrDF60689R3dQ4lEqJrGye4xcN2dnBnRg3lzWElLY/rTEeAr6/YRa6pQwiOeELAAqhlfU3qi4Tz4rxdgOcO09G4rYxjFHYOEFGzEjWaC5qt9w6Hvr4vSXoIsdQeIklAqZrqQGzfzorlprTRNvCGtrbJ1QnnzMG/WIX3bEwaFPCO3FVA3/lxZyMgLIK5Tb9NNa8T42IDf12YbYTm2Oj62/1VIqfOqnwlQfcouNN6OZjoGCLH+7Pjk/YCg5QYk3YgucK0JGofq8fS9ti5DUGO2qZ9ckeJp9fHvIzhrNHePYu+Xf+4kPd/PVuFTA4cl+/iN4C+bVUrW6d9+ZzyH5zgk1w8KouCbOPc8448ai+Cf7dd8WQNoc3lwZ/Jru3+RiGOKwZ9fYDxWgD2+CPVk6eyTxNEDk1sJ4sYL1Neb55qjdSsSn89hpynqKVyQDyFTuwkXgSl4ILKzYW9BvB44AzS4xO41rVJ9xZkeyoDWIyI8S4Z3u2Xq0RrVuwqe4kwpxXs9O9D+4wDR+eOjQdzb7wMmn4CsNE7tOEH9lmh0pPFmVoxvE5SgijpV291HrmTGQR/M9ALt9TQT6zUYYwkrHxlOVo/FfzpA430ET9c1qv4VhxmAFGzbbLYJgTreswYpivC17iMEgcTcBWem9ZsIzkcxRILfMb+gNHmf4I8GI6aNC3cbFOY/h+oC/I9iXcGap2/28dSf7UYK3x4Su4fzu0zPiPwJn+13Ii5TvhuS7yFN4VuBw7h1KGKw12jswzSPPteJXUj3AzsV0VjDFR/V5XNLaZOJn1ZGf/0TCZieBporKBFksSgRQurqx7q7x1p0tgf/e5R42bHR9WjAtdziR6oWLsB//j1Cg1c7F+opZN2Bnqv9/MWeZi09akysxfJHBwHowPE0CmrItSrqvfDa6zDqtMMyBWQ9YlngdloLY+D8s2wpjzBMre4rfYhEbfFg/67IHTlxiribPS44BReN5tHzT/Rzq863dB7f81ACmMDipt7lwCIVv3+OAehbggoclnbWAL8OgCQ/H/zV2FFp006y77Imf4sFqCnayxSDmStCGOpK1J/PDBzJ5yzDRSYK44pItf94cjeH/0BEVAkv6wTKtC8D6B0q3PVa2TCQaN579Caw3ExmPNWjb2vBJKE33H59Er3bfCWIrPUnW+BN5RhIyJZUxjnMUIWO5a08Xh0QpaZJmqBfB5L2SByyTtKACkmrCSe9AEog2u74T8XIm3gi0LvrWwDSUY+btqAXkTL3s/8/P+zmxTfHQvvpJWnrKLl/ad78H30M5yfDZcfJL/55c1fOtxObzh8i1/+olVX+o03KZ6x85oXtxQfzlMWV80AorbVeyCWUnDfpT22LQ6+c9VETShvcuySkZhG4Iu+6tXlcngDw6q2dxBvtJVX7tE0y8tNdDmJonAg5fAhovj1ekSRBQ28GY3deyGsKMR6HMbfrkjqJweYed5ECQzA+EEq/XrFi3Hsp5tD4PiSR2eHnshW3gjao67RkSTtuyh5nQzla0YcCk40zqB/Dj/Kp99lEa53enmy1YO8Xxy8ZCzmnKSuAu8Y3xIX0YKB9DPbnrWsW35sEv+uKnV8Cbd5h6uDO++ll5PCbQRB28L0eni3AwNuAUXQKdBoN6ufvGeZ3z2ZVVQjMCNOhferczIDKliokN+f13+d1T0YCmlpXMptW/6T3KF3veNO+BRW8/dj+bBCgH0/qCfRLMu1w2F66F8jKHwv1fjfrvAfJ0EQ9yk5ENhP46KUHstW58IJI49gf99a7MH9hYe6+aZ62i6VI8yJePIXfpeCXUe7DHKGG6ysCE+Bgzy8TRhHkq0SCkPPN/ossEBkOTe2VtqntW1MOzKlXr3MytFTW0e4YWETsURlKLj+3Rcd1e+EoIuEmTM2Pw+MmRLm5h5574cBet9w1wPMmZ3bNm5eoHHTvmbjMGu+ji/Z/ypIrm/vVU/Ou22vYf6sicW8mGM+bhg7KW8mtNiz9qDFLgMvxeIFEnWGHBwgZTMhY0v+qe8ngfKZU0cCH6Y1REFkY/ImnPBNnvVcu6V1LFyaIC/YRAfXOykZig+8EKXp2Gr6rUgwd43wUOZa8hmYI20l2hZDS6+Ek6HCm3n4UEWZNJiGYNTvBXeODes1MoODSzpxNkQEYZafouelsp47/qxbot65BTaa9sonpn3RIyvp7XaNiT11R/dUwGUvx8goaD49YT1TywPYPXxSZY85tomY/f/EEjp/KZeTzDxv0oy/PU4Z5r1liIW3fNO46dDOx5pf9ucJqr3UrMQ0XWFe6n5hnHk2yXSCkWkLzM3Qw13H+9/UckgbH8Y9tZWBPjOzP3VppnbUWy//8ERhiQTMAo5+DzhMAn6oa2KeV/l5DknSq6+qCipu5MIj279Xxfv1m+vPpO4XIpX96zWBq15X8QJFyXcmfrgcvjYxU7lLCu6slyaR3Gewc6gFIs34/ACpOsR//K/ONXW8GdbvqEIKJjkQ8yIoyKaUVga6HCPd5CBsat2Hxu12Zbfdr88QrA1HnHl4d2qrFxFijNQL7Lxs90n/mbxhIY1INH6RXa72lD/z7KTxX+flr1/MOhbRwjGI/ckUvq6eQt96bJUHEbAzV3U7Sp3HJacDZB88ZaPwU8PV/vXthve8rNTOYekMoSldwEC9u+vmQ/61aJYr39q72y2v9nxrwH6wCGTUIaYSd7+yP92MHViuaKjarPemh0D4syB8aC/WBhxtp8qn8c48RIExwOr96ccPm3pxDuWge+YU5zXBElWT1qKC9rHqgUdtTYOz9O7NEfXcUDAYyJRIlK5sbb8HoqVQ3T60Ir4fJ80zrm9WCZuvcFbjBI3jcOv6K4vTlDueWcmc30l9EdblBPic9MrmeU7FhtvKDoD/mBTaVn6lDQXEWBBFc+nvuKNmA9X9M7rzT6oPRbcRqn/+mzGZwYA38rn4q2mC3vz8FqJAuVzWEa/Oz6/MBnjrN1m9r7ySl5Eam/53nu+zq6yp3fofLyBUareEMpd9xwdsfRsGSWE0TGfvr3m/EGMFm1ILqlCo9YMnYnueY/igDv8b3pqjdiYnP5m4MzJa6dkMbqkimCYxangOwKvTAV+TCWOdqaxa+UCkyFWqRanLzXMLgcy/p5YrTnyis/672sk1o9LmchATdLXXEPYwnbY09Kd3bbMI5bFCSJtXofUT30o5jM57/pXbKGhZQesX1QKEN+Z8UGzFwI/kh7uDfLkQv9mL4Fr0aII9pfGu9/KPNELPmfqVBZ7DzaEKdHjOjfxUdyrA8Vd7e4vauDicBI3RBxALUYR8+PeMmUr++kzMe25GApuzhNKSVfLSftxPWZ6fx412jHdecUnMB5NLLIvl/97PBrA1O6BNK2ZF2ArVWXkgUEHj/4mmIxeU3gjBZ8FD4DDhYqrqDR3ZiEKhlT7y/hJbUcuzzMvNHLnADdLxH7EeffG8/V7qPwRORXTZL6dztICIpeCXJAod4FixCdwaTPn3+a41akUTsQjxCQxs99PfXiIMO4ioVNOWf3GPszYe0tC2LpOOgDfbzF9+4JbtFan08Ee/e9GFJ+bZFwefyDYBhSLjR/nyRF5pGSMaYv1dWMl9AGksu55llc1l+oyxNx7neZuFK5ZIoBcSCnWqtoLfXlSSI46Hrq3L0P7b/oNPV+6DeBFiyRuYagtdR/4g0IK5nMiOZXqdUm+9gBqp202fFVcgtUBrCeeiytP9nbXKOuyNBx3VQ9fe9v7mfP7b58pFG/Gfu5idqNIsOwRhIzlJWKbZE1VorRkJduMIBo6qAG7DMLRwxz9UjyXcydioV2nZVD0YPXDrB7vEyqB/Lq8Uf9hs/TROU7YU/6E8Rf7EMqF9RgHW6ZdmzoQM5z38Uq/NGVOWRl/z+w67ootRRgPwoOlP07wA2F0x+D4EcOP5VjaZKLz+hKgZMHrH5oP1/eh70rSzQURmUi39/HokPkP/cfZ7DXwraP2pOn2p0AhBN0I/am1ZJ+PH79YRgrT2vBFiEMv3E3sKFTkm13LfzMKsCo5FFWdEJioocioIGw2gP/QBsdS8bScA+LTUVmrtsrdtoPmV3FoPG+p0XUomkFf78Kj6JjoJKWPuFLiwyAskDvNj7LcDiK0oiZ0t4enVllMWtsdFl1Fa10VH/eEWdsviRBOuWg3dtfg947VexbsuubxdAXwHblYLLJ4EiAdp1neCvK6jl4KJj4wGExbFSgp+7zuDEVHucAoWQJahDSWJMvAYsb6qNxPMcUHf/fhildIXK/23FZV5EA91vfkZtmLHcia+tnefg5z1OfeXaoLXeARxc9bNiyAdMRCr/J94cQNPaqghK48EgFI3HoFJklh7cQkoG0iFp7JZ77wHTmcP1u668gbQtkhaaV8qbs+uq541c6C8OFvsZK8GFFkFwc4KF6ZX0KmvQNS2MpmdWZj29P5hHXbf9swbAppgFsRHZn47uJsXg7krNXg3/SQOZ/NxapGRmN8rfY4V5Z5LtAKnOn8rb25dCQSmApm04HY56K6feW1I+/GVlVfjTU2nhcxHqhKTT3rGs0EufYlmZid4NRW7MlAXqXn4avduQBwwX1/pKUmHx1T5XOJh4EqmHWZSDfrldelt0AeiTwhj+Lj9JA5VPsd4SHoZj9VWfQJ2KZT6lJtk82gh0NvF7APrsXjbflc6UQeNq9c3BHFVdnO/22y6fO4Fi9zZfk32Qfiw/Y528DZsrd3/Nt0Z3HKSuk8GmSydynNFzlmS8t7TanwH96CHgJSo8pKHZMrMiGtH4F99letcQolWzaY8IjFE/HlLcMXT6iaFVbRwPDwurZzcNYFhtu3JQ0Fanh+WLfsJoJevWkhpQGQIrklM8ABqtTKCWt3j9xAvWjkFv3OHwKzkxXzY+s4WBhssCttbOXzef2X1dVEfYxGdd94/NScwUCwgjZH70y4O7bDD+J/1yzv+J/N+e/if+wZUvPX4U0W3ip6PIPFyj6MXyZKFIzocL2hoakz2ur6eoT3cbUTMrP7HGDfn2eFhOXcRiSIfPOutRUDgu8OovpPDSnPj1anemQm9KKKo96qpxyR37Op/Ph6JS5iHph+drglKXsvv5qhYH2KyKS1pDjaqFrC72IdncQaax9vr/CHuvLcexI1j0a+47vHkkQHjvQb7BEo7w/usvNqp6jmbO0WhptDSqri4Wwb0zIzIjI7uCwRaof26wpHGi1K+xcEAKA4/BhUjbVw2bPgoEvdPgK0dc0qYbOfQHB557HJYLEDWRw5w+qSw32gtUmSuJYUf1noOLJbB4uisjkSUFbyijCWyzx3KV/TB3QkaNroxn7udmIm0qHfvzx0cRfpTkixbnI1doXrHtg3thQ5j3aEqSrJcnm9qAZGRoDs/u2fRch+qNhHeHvT1wn7svtM9bVwyk3Uai2sllwHOD6fe3nu1Hx1jiIhHBp6yhf9v98f/cr/VnyvxlW88q0luyLj4/UU25yKsTM3L7Yd57UPbGlcJqKQAeWcwbVCkPwau6hwq0U3r5ZGB11C/0gVCuHV6noDh8RVee2RHeQpo8N5MR4Y7U0B8iD7ILye/ZIrjnT40gLUmzrYZwp2WoIrTBR/2NipKYnC/GdbivFITthOuwrgVznkcyDBTwtJyEjXDXfCDy2AEvJIFfbzFMc3vpmfKZ8Nyf0XHHvVyGKK9xPVjH6OV4zxkShigmU0U8cEgnPovthb+NOZeCiuuxoaXGK4oLVeYO6lOULe2jZuRWiA72em377QwdydEzZ9NBeNprEPPL+e7QRr3glv+Yv5nIrj2j3B0WyiRDxK444cm0rdOYKFDB6Qo5BXv6FCbUU9q+w6jEV2lmAp3iMN29YuxWiPbtrUnZPMTwJrk+rys6ezbjM49Ox4SLu13fGyWwgR9EQGGF+DGv49E0JfsvSNS36+daNDhJxgE1ENAOntqHKuNNrx/cP75bbmSN1WhXZfvy80fPEjw8aHqL8jit16Or2Q8MsAcLm09y3miwkb0SzJaRzevT8eQLp8AEnh6lj8sdTKJ3ndi5UqSeiFVVQKQqjAuqNsj6vYuq6/Nnm+p7sqYdTN2plQwHJBGWuqp+K/rO8c32AXiNCBevV0SygxR+kUCVlgb+PKdovBASv142bs7DSsKY+XxicZ8NpcbQg+9AP0hlKK6nhouz3Vu9+wgF/SD1lktORzTNskaYHzOpLrRMLTIX/bBOTnaeboQskgl2F1GtsmRkTbXHUudo2lLVZwCVLlPRkFDhtbVCJzIcGyjWk4nvVoyuYzKMCV+zwDV0qNhy2kIeSrATJR2HExoQe6JIKl9Ao4lZcAdb67c4BwrpGsDe3OXraeAf5JK5J8ATP/a+6el4RMT87Ix6uAOv/Jvzp3t7PL9kXfu/PJ494PFcLkr7ZzPrwFqM69tPgx9lcWAmVrdyoLHBs+bkMXxBRqeHLelkvewv7zlp2jcJwKlYxuizIIhUagw8ve7w15HDN4VNluzg2AL2ol64gPypfSWogfzlMe9YSeOldv8WL9gGUe5MAYrY2JbQ1kN0AJ/+5wOqL2ZSwpW25sQQWgqRSW7mNL6RK6Wy0LqOkAlrKt+OLKgiMn62ASA5iyW0NfTm3YCFXmZ7YOE7vyI+kTUtGrOvcr2wcxUHmcxYpVdjz2UDknl+oPjRaj03ksB2ahCUQbHkrKX9hNHN7C+O8ut9/ihbExvVuAey3/w9UjTNzxw26xr2Z6agbuSfKgOY/34rdrP5dwV/CtKGZIeFzBvW1r6MTl1ho/iIfQFuO16fbwi3IxyEzfjCuRjSCteFK1lyEaVi1x+wzvdXnBqI8hzY7r/XAdjPAT5H24oU4x9VJPvnHEnxwDt/tEPPjxK10nubmaNWLI3lRLC9L0tt4XpkdmBUxYbffhl3bfP5fMI8/8WH6OSM11pNGeha7978UiyKAErqZ668sINPDbYzu2Qc0Xrw3Y+k7nErQvkFBOgV766IiUrnFT/29+ueuZWzt+fJZFAomvN2hnSVuwstWd3+xsHM5BqObzq5uB4+wbTU+uQcTvjFk6E3fq9z4OO2ibiOUApq8A/eZlD+7KRF6C6kARoaFz2pfSitmIsTrWBzOkTIb8LFCt5akeIJaHp/RVfrWUtA5g1Oang0b0FNTLe/KDmy0m90tlJ6PJaVXEwwBXO+J+QgSEIqcpkIuZWFACJuPj8c2yZ3EhDEJg72adap/K4x6BQybOOp3buTR01XEX2f6BPPSy3LAcqVfUg0uLm+tSajrqPca9EXE93aK2FpiWSfuj1EGAa6mndjxuM//11Tx8saUBSW0GQF/+i/OKC+f/Eh02OpP+cGf3gIYbtFlEyb97Pv6fMRJZRRDL4IsKC671mmWzxw3k4/k2PcXW+HqFND4H2Yyhb4sGaksqWOMohEtf/SG6aq/KcCubCm9IQJnau4b9d1x2u/v4G7njtYUsCXttba7YUGOZ3BY+8r8h6RebKPTERqYBkc0Wn0tW/N57qagrBHM/XcladyYVM0xmmD2POVJg05kv2fD8OnB6tNndmYAzL/fBpdf3zvlmSD/s5jjTm5rnmavcX1WXxg0MRlQhsvcHH1yREeIBXMZ7DA3/3M46n0l0KXLErE770Pa1PylvJzdvi0VRAN+l7hbK3CaWjgdMmBNI8gNzM9YAj7rmQ6ZrQbpumVE+F1odJ1z9u1uQj8Ex6iRUvtxoFM3Ova6Q38IKA9WUKhyklscD+ybbWbM1huOZwkWNRnki798y7otLH99Dk0y8U2v3kflqa9SupixBejfo0c1Qwv+7O+ds283sjy5JfIDl2kWs0wAK8jfs/3EtiKTF4v+G2bOXGBKmKAlbsn5c3NsRq9yeJXTFrRsQImlczTMPlxtTWYo0zx59M2uSM3ZyQDlX5nGoSkAV+tRPNFDl9vyZ3Uf6ZvMN2fo4SiPemlvnPrHSdX8O/d49/4DOPciLfrKNz7e4+4+TnxBzS8pj/9YP7B61KtYd/hs3TulfnEmqU8IG7TQQ/zxVbxNgHfVGbCWjYg31MUe8G3tyYs0AlEtXvvdItAp0JkijTB1vXIjKHfluHFShcCqPfTF2VcJ2WElcL23gcnIriaoXUPv1xqkbJUgbPFZozIjGBONAX5kIawVhxYup76X80ewAxg5YvQNkegK0rW3lcWYphNX45yu5uDmo2b1CRwxNcEMjGk9aQA46IoTesq502GF+E+rxianbWzjp+X7cmvIv1NvwT8FqjwUUS+3bufTvkIsmIzHs94/V4yFg82HlMLuwQHT6hzgE5MzO4/21myY21MkjZq1+62111fwcm6voPvHH+uoARxP+8gvgh3+N3xvibSqPYyo3WlExwFghgWT1nA9jMeh1G0A28cgoPv2s8g7TmI/+pK91tKoIx2nrtAKN9ljKcqaS7YZ7xA0qXQoXe49tmH2afN7s3uWWwm5R7XD9q80D7+Fs69vh1PwTs2lj+TzWjaNa8oU83DM+DQ8NRtqxd0yKjXlRpu/b5wd76tR8O8vURlJiQxXJ3o1xaCIKoKb2seyRW6dsg8tAKHJ5z10up6N/C7zb5DvNFnWPNmtMdKJLsIXdcQfv6zF/m3fgdb36NFsg9Pf/eR+NTFraJyL+4Z/FHIHRz95NQrv7+T1PjYbBdt95ZqEAiOUKvFJM6szGfwvmQejf72KBfQcr/Qed1a2Wg+et3Q4jtp5mCMLzKA81xR6mL1tecDuOZgCnfvHSYcqxycdCDwRbirWLg0ORxDDDAocfDHxcoD2zeaiO3Hh5oKqqCu6CzHTrJBdyXBtEG/ZjDXHCje4vBi/l0gD2Bz5lvzrSFe4yEMU+QET+48xk9sqTe2u6IL+UHFqoCBL1CGmw717lub2er3mbUO2w3Xu1Bh7pQj1ZMbodWvHAeRvwgM+nxUcGYTthtd0BtFwW5G2/V3TQGTARU74tv5l5KB/eNKRqHx1E4pnEjkxdnwkP0MXoyJpYf7zYi3zXGhlSlxEHTGKOPC5GzsT1R/Pxm4jgI7ppGEV+2tpqJ2IswLGp9DwPKgN8mIFZB3EX5p6gvu5v8S3X7reH9XL/xtY1gOsDxuJrb+sn5Yvi3ZkGAtF3pqPt38Bhp4aRMv5H2qX4mR/vM1ONZ4JE9L+EBgdpWdGPFkT2FZ19eFAZWzjghqK70YfpY4XVPMAXy4aptxCcfl3LtubKAJUkdtCRBxVZZQeSWnE08KfeqH1OQxUonBzIdvOFEopAATIomEq811BKHCbJ8X7e/l1pea2Adjkr5gUYsl+5vPE1ETxNr3KSAPmeW6eaxn391BNaAU8kb4XxMZymd7hJTZfil4FkqK77bqnzy5Y7q7w180RFn90SReD+g7OnyiWnX05JT/66Ze6QY8cal+cswfZZnsPAp5csqkd3sE+KHi2pACfJFq4bO5zsfFm2RIE57Px3ir5UCfij+A5l3P5WBoU5LrADy5tQ0xgV1IEyHTJQBfU61YAQtisF6sOxpNlcY50BVwaqvWn58dyY3QFFT6Nk1NBp8HtYVikGbVj1INAb4nsHkkH+l/TVl4zeeKMMl49Og/OjP5j75KnAjvz/wM61yQ8ekwraf4/w/trgSeEfvwxD/MoresWjTQ1xQY6gxE9xfoFmQVGOOig6XxFnIxRYCuC6Ahw0A0OnO7py/knH7PHVe5ItLAqndooZErv87tcwPtvOKKC5t8+2llTzCERyaQU0wmWBY2tRdkkyEqi6WUvJ6DhmN1xnQyqGX5tm8GLyM0vgBNhuLxHzro/7mdKunuPYlBTnjPv80TMZZ4Z831+fWlz++kIQPwiTwy7sBI/9d55ZjbBSDP88+V6OVH8hc/u9itVaKMd775hw/6nYNsAKV9OPivGWTNPAxVr2fPCE8XLPr25dvkRhLHi1mdkPOtdQvwtPclAObaqV1B96c7se2tHbvxmg1bgvKI+g8NV4MichiDJBrR2TIHmuxnJkMcB0OYN30/UB2wNaxuWaA3tq7DTCbKHfOIFzdR8v/c0MzXYBv8CAxa+H/sTHUVawOHrMa8rfsT3Sxwjhjxpfj/2If4j5/eCZaCs3V6ofi93269dp4vwmdLzO/MOGJdAV49YXQOIN0UGnokjmAmrxlwYNQfgPztdl6gXWmeeRh4/voBM8gRJ80DfTdOvi7hFU4+qXuhktwUa+fsMRqMsNeNxnv4NJsWJDC7R7w17b0epTJCU2maXd2d6bK/DDhLAKP9oj1dbFDMt9dPL7wv6HUh/vv9/9yx8p8nkH3g90kaknV41H/HDcYP3xD96Rc3MI/t03SvORv8aHLCehn+daO6zBjbEzoiiJbUjk+uu3ax7E36DmVDo+Ee5+EA+2LVVjDJtyNK9vARBm17AauwT79I/J7ki/ivXVb9WKA/wdwgsAWSrqgHdi1eTGwazig1vgrQJ4cmiO3mE3S4uzNq3P5Q1oEbqhRUeyZ0cqpySIxQaTKG9cekehK3pZKrCv+2AeC3b7wDZx2Fxnj/nxH/sYH5sODhyvyf3Kpdz+lR5CwUuMgzDu6qqsdO3PgzI8SLXzJbQY1+vhUGJ91Jo0ZRSHloV/4kLDfL13XuOOQZbr9x0wRuQIx8X2yEzLtXYorbb7vYvK8LOVvF9VE2znV3C8sAglJxgTKzPV4zHPokdbw1RPyts8Bf54xUPrBVadgPpvtuOOtbZNxsmCff2xs+liLbj1Cwuo/DFvqnurKwKZIF17o4pv1URc7507Gu2EC+icLQaQbkgBJxI4UXVCYYYnoT74lGZJx34Z83rlbvFTC7b6oiyboAJh5rd/qClTdXmSCekPd+x8VtZbHm7vmT7X2Ei0S1o4h03H+PBbZyh1C27V/F9B+ZBlzc1c1JOmt+A9Pv/1y4NyMtc5fArgKs5rpGQntBKq//RIprRxe5m58cB8eWb+FB2QS9H22Dz9VeL0XyqnuRr9rT/BmjLks7GFAMPG/nHM0jNM8GlEYG+so9GSXC2TGq4QJkqzzIVHEVhpMJNGX+FoIYD0R/VRMrjvEQ/2iK28eVO99S1UN/JmzTDUzeNNznFD++AAUKAbgHlaULmQT2I9dTAbu91Odouw4IC6JLLK7F1vnBz1s+cQoBJZOt89Qx6M0an1pXrqyeo+8lJCyoLu9H6RGHn3rD2v/VbENRSD85IqzomE8QMCR2v5PrHwO0z2EH6dAPGBX3Gu9pthUSo0/9FkRdR0NNL8DNDHA0lPO90Adcgtpl0e4JVGE6YJjB6K+tY7dXaPjkYQxfOMZdLsR8rze64hqWX/c/AyRV1a+fhn3vEkpKphca2T901rogcBILV9vQleB3J17V+nBqm+SlqFyQ7iTGePwlquTMFh/s27yaJl6V2rzYb2bVIevBDm29NEHPVhMafPULudBwsR+FrsU2l2gSH2dHej2FhsoH6ByBtu9NlLxF1g7QPSpHe28bP23xY8xSIqTSf58V457zT0VirtwH9ffv4y5mo/XvP1+L39FDbjkniV39WVFpATFvli2IIsLIUFyVz0xFjVU4MHi4slGmRitU6iKK10d7t6kjGVSNuz6C4DFe2mCE3fSzSeyHFSJFL+P9EC/AEKT+7Ubd9+g7sfox+Io70GwjpN17OH19gJSJUZlRrZPNs8lwUaUR/7LKa5jPC0Sup+LFGVs6nyR12AvvssyWhVf0ZB9jJxD8AIqXWL58yL694kP3PD5dnjgSFwkW5yB0tEueMzyHvl0qsvsdA0H7i91bAIqaNMvJrB37b6wRfzZGOOQs7sAbTNBEFGzJbM1wRQIep7+akINoZxesSUuF2SNAD6CoVBlF/ZKAC8GAbYuF8v/98autRsVERm9gi3Xp4BueEG+F9+FIO347FK+Y+vl2Xb+3zGa9n5VK0QGvVsBZO0WyDzrNlgDOQtOYfgM+YH/XE92aQ1bA9cOwV7KBGguhn64GHbsKwMI5luFdxT0w7QUw1TuSTdfPHlBiPBkFQjm5IcWAg54bHA4CfCG6uSYeBFTE5mZ25vZft3jNX8D0YwBAmPXvnuEzA4FqcXw2lettf3afPi2cQ7lgqzsS/KjaazBB0sFYM7NRxRUtswrg7tgwrlPYnlyTIHK02/a98WrqvIlQMHujgSID5x4YFaj80eqOwJb097rHRdBEd/92HS5C+fKI2tWnRj4ee0Op2yw4HZGCqWzv4ltBlsdT8OpzbhR7aXMI93u+JlZsLuqL1JNq58Y00SYsPZonDDsGAwaQYLsQMNkKQwpR+SqdSr5DuX1457ZBhIfzqcZzC/LcVOTe8flGonITw7jh7rqYqzd8hwMsFP7iKY3jOGKtWqrGyorIXKEBmxj8uXxNeeYkxaZvlul4TOSOrhE9K24OBTc+/PFbYnhshyOM+X341bt8bHFYtoWycXoXL/oGEDLx7vxQi1cu87DHDOBnK+bZ3ZXYSobYivbf0EmkTmBEX8y3/HG78tHr0YaLHe9r/bND6D8QncLdDpke6/TM9kcxSDzkL1WKCkK8Er24njn8sNORMIdn80of8iJ2jTOHsQDCx0+/7Yzb21PkVg5azg7i5ddhOOZwP98nTM6fGtHlSGnZgNlBF4U4kq5/4ylby975B7PooHqWq831K12J9ylDn0aUU9c2IrV7Cz24JeuJ19aTZ+9bsYRuX6/fUe/lsjhnlOwcFG278fq45hkScV+mKTruGqMP+goblKG1edfXuKW4foO53IHHVwA+Ovr6U4POn4VFzCOoPigpLmWg7/pwKnoBibLEH3dmxSfIZ1x25F3vI0X2ARH7eS9On7mVaevI3nzOioRiACUwrHOhFWbFQ7RS/0TZ2mvRWG7KnTKGOge5EgnVZv7SF3GXweFP7Tm/EhlC98vn1ZNAL05AM2qnKNiLnQYBERMOKUKeMQvMBSMjUhzAR3xUOkTwzCi99c2/2DLgnkE4ksrNvvJxkIa2E2EXaFVAZ6upcvwEy36JYzJ0gs7OFEfMGH5Y5x4h+zSLyFxsVC5+miz1aNNen17Ajdgwil6CDWX77Sc8TCjsW95lNHPpmOqe8k7xZaLjo56/ZLLoYo0otlxDKtXt5PusZr8zMti+4sQ3L8IUDHu+tYsnqSca7xzafimClL0l0J/t2oDaru2e8DHKRC+bPTxW/V816/csgJsJS56wdyOukPIw9rZhkPNy77odyDLNT+BierfGsvruKBOQlhL7C8tS9exyXcCJK06rZagXfEBVLQLr2Sy8Io/Dvh8Kp8A02OxzoOGiv6Bg/bfpsET6Yesy6TH/YOtfy3lNPvHGMDn887XHRwE6QKnY5ofziSzgu/yRFr54wqA3NZkP62BvZ0hw0Lvxu1driL2hJEq+xHfVZIbwnXcJKEB4p6W5rfMIgU30+bGeNzNdYYKDriOYKFaarcF1H4GicFfsY9wXnvavE95WUb2WJ9jhS9+bcqq05brlOmXX2diwizAcUiGvu9aNlB6KV7Kczc9Hnx2U3SLQgsc167Vc+dF5fRKbqq2qAOcQA1uUVMDAyg/3Tajsr3xKg08w1I9iXpzzqPuXMIMiFU3DKLj8rlwTZheNd3ehXhM9fqsO96y6LSyix7eAdotja+bxKtG0IS0Pm1G+hUKH9fJmBO0VzDIT5mtNpWxVPp1ufNoNk3rd+dspA9QcFJT8pBQpa4rrRGfHuTv8xGWJiv+CtQAz+gkqVCiUO6h7/fd1N2rA0i8Go/kH6CFDyKY/d4zSTKqeerv5dT0BtwGv74FXmcSBhXr62gPZ2+n90D3qsWRg+/qhDJIJWqklRjia+TNJPb2aJ/66ewvpnjDsx8ZjpthgVThfQaWGdebl6K+qDXRq/0zHzus4XakTFg8noTygS5uD7Epi4Hdp8Oe88hoCkIcV+nDqvB7+GyD4h+aOTVn4hJcZotAc6AAnS6oYRL4jWNQeTrENNcZ4qTPrVoOk/TmEcIDTeQyhdSikbvUZrPg50KapAqUt2BYFRJztwPmH7yImgrbramcmceGaPOuFKSd0ey/uHafPuhJ31pg/j4/tXHcn+pnAVLqXLB/cf+9b8Nxdf2Cb11BYf6/MaA9VCwBu2x4Jl/589dE9LU72Txf3bK3QrD/T+Ez59O9CjSlZQOO3s5szfSsbF2f+0BUskvmHncWl2yExexKG6M7fzHYabScb6MgDh9z6mmCE1IbudtyJyEll5dVYDOz1BN8FJ+7qZxG/KSg1UoP1jWjUDJ9OYYxOp+73ICEnB6hoTUDSy7gTlzamrwzU2H+w5GLlcAyB6QZ/yDg+lu+KTkV0c2mficwWYPp/d99P9goTLmCuoNpQ3B7y1sA2jHnQNsgu4L6DqcsR7kGWKE+rFXmf82LQN1f5TijfSIHtr4senbjx8eb0LLOkVjtItWGYUm5TfXqBx3cOZRItpMdY8WB+B31mDn0Ya39BGkAdWY6Y9lotGK/9nqznBQrPzrzQB/K4zV0T4bzUyLxmohdyuuLs0kYNUQBgmPt2Ae8XUH9bee1RAPLquzeOGNE2HLJ3YgVHPb5RoR2MU7BqFAX0ePBIG4DrRRrk5w3EuPn6KbHMJUlwKXJu5SBgMKobcfhJuQ9DgtBwUr4/HX0wJXCEXc8d66dhMaKUF/X5pL7+83qy77sEynWgq9CSL4jHU2LJ4oB4OdVRzRglXOTmrnXHXp6GeUMdF4ocsqQU0FxUhGwi1ebt+4j0I8jh+Yv5Mr8nPekFy6D7M0XbY3l5Tz+ZgGqGdyfIeLz+ey1Y6g2QgSBbdP/ZsWFs5rO3Hf98SMufr0VW022D9jXH9whTZFC8qsSjuwwCk0rXx8Lwx9QFk+UXGKsWpy+wVlh6DUrqH9vI4uvTC7+paXS9PGrimYRZXHTSflf7OVA48WULLj2bpi7uMDQLteYlsrPcz/vNHbd0sWaTKjT6E1B/JKdBn2R8t0vFdbssW5DfthfDjgg88gkoqb+FW5LXa6Ne8ymepNMeCKg4E7LUy9wZy07ykamQ5VK16i/ekT6Gx+AF1TI+WbXPCJ0sxd2wIo1cXX8gv9zGGkVmOKX1npHW7ZuaVrtRlZlTx2PCXsHvxAMqqc7ne0ID0tbt9PS6dTaN9lbPu4LkKpEJQsqtkpmmr2h6poOeEOQlve/0BCFdz/PYhre5P59YH5BfVWweUvFVGe+FP8d9f3mUf+UDCqTCirQAhGVis0Vo/AKB73pW781Iqo+yUPqiquNDDHX2coP6M93+wNflu17PtdT5CdAObdzeUOlBtq17wdnHzK52TR9HQd97pAe3lQx4fAr2jkWl+Y0UZIdQG2PDpYmfnv3u9KcF9fmmOSnSUQkpfgvFjOcvdKKnhWgtTNkqSSzqvKV6MPhOqyvKMFNLs0pNGih2WE/3YngF6dYsJBvPg+3pUDGlHLOucZdXC4pFNAnZdl75SxQ3oKrUfncQJQKipXnQkuyMewkiSNd2i7tKMp92Af7A62e8RUVbTxGB6ElhyT9yYhP3sgWjZv57ZZ7pFOviDAfU2Gf9d1wWdOUmnXBUdHMueb8zQJJVCwzbNQJQyWyMN7y1D0vLhdKBjfaDAPoBFKhM8GBfL9Tb2PZOr9T/RIGKlAqBmyNw3o8teTeVIaJ6CkyNOBe9UJuO3mDNS8yGI5b3C1yZtPzZ8fhGOUq2Uvg6+RumTDwH1KxnQK/XkQJ5wOpFPeKj1Pk4zQDqC+uMcTJPrHChII50MM0re7TtF6I0QaNWtvWtEmq4R3/FZII/uqGfKdIUe1Ae47sMxd8bwA5JIICtAstY6iOdAyHZDVQANoZey9YI8q2g+cETLoQfPlFeINyFVAsH2+iKJbAXXghctxwGOX8T7QlMW2iCE7TGT/LcK9qlyYeFVWj9mxOQFJR9OZXLFKtMRILYwf/2yePQ78AXmBaPOyW4omWJnCYztE118UueJvKpQjNgbs+AvokMVdqw4oOXuWaoYWRzE+vXlbT3eK7OQzz2JNVPdFVuZcRwhdSQxwhlxXB5l2ODNrMPhn+DW/FtHvEQcRg2CINMpYkwm+TaGx753cYcsyHWW1uSzlZdxpH7acng1uJxn1+ZwTajnKy/wH3w4NPsUcgloFK08lgHjHifyErmNUUASQTc+hNw5qAcVY/XNXeRhevIAXFcgHBfVKQNDIrfe3Dy8kNsXupxtHI2JDTuPwYHzEAWrvFfN3xcX0JUmvxex1x6MLLN8R4Yct2MRMd7C2/q5gSnrZy1R8S5/9Bjlj97J8tiy9s/Gl7o4UIfjPpm38apuc39LDIL1Qpqo9D997CPZVXTu2+EPC4ivdoxUif+UuvbQPfwkT+8jD5PtokU7lglu2r7jPXrGuU+C9cE9UR/zzc90aoPc7rnOXRU5kj/auSzVwflcT39ydM9wu1A5Y4kn7p6wAmm1IJFtGoxmNN4IXkPjtM1PJdl3Tjtt8J/yDBzQbZS1SCAZOt7uyeikGk5Eq/1mYKNOzaPYV+SWHG2zlJHvDhSns6gi92OIQBj8wBHB6jLOZ3zOpttGx5UpAvpXWB0ye444kS1wWSscAVH0PpmxObAMwSN4euL16X/aWYwqITfUL5u74WbY7dHIHrdLuDteKxD9+TKrscG91AYBUegGXnyXuMSeW8VnAMhNaGM1WHuo9GchzFc6XAUvvCJzxxE24d/zjhBHMgImgbNYWS0V9BdAixOgIINKIT5ZXjZque8p5iPQrEYBKrtKDtbQglAIjOOY0i0mcbPHHI8Q+SLB6LJeu8QLhVBOZV1J+doDmCnYWmgk+YlJK5o0+6g9HwX1xWhaES3ChGUDWszc5bnR8HBDR+zhiCCmKVPmMYX7bUgbAaFz3MKKZJI4G3mITL5oHJnNBKkRf/ew3WGGqjYsy7RyUP4G+rRShNEZ1DhKm3hT82qLx8u2SNQVKB2JttyS+HP9AX2Z+9sUDqDI2UZ7Vav8c2jWS3P/Atb2ngOxrld5CcHKjyC5BcSdYi7s0J00IzFg8bHXw8APqmmlB9eXd0HmuNMtv6ZFgKY5P9RmWeiAq1hOPrlErqVW7w+qT61kQoy/uSd3kgmrsBT3yYnEki2D2+BQS36R89HzvZrdmWAPRuHYErI6ahmLpGxkHFFpoTf949z5ANFeKjy0CxDlRUH19wkwDXcPdV9LsZ9CpJiiKZ7JODJjHB5xvAJzXEhxcSKlQ+PKsBeaPSZHtyLFPLkgEaLGhbzeez3/EYsy76XuPS6xOXdpee6XQYhu0sSrKBFQj/wiRRPQhZ3u3gBlahYFyKMte2PGyt9XCCL+lpuOUXm8D6FA5Qxih+tBCRLKVo3IGa0+9TwPkNTlMS3Uxyokws0sGoIZXVaqObTeJudVIBUFd4eKN+v6ydYYADicLwP9NuyPbgJ6BUsacocKy4L0ljo2iVZgG47vThTNm/e6NtXov1+f7TE12U/YQ41A3PE7HV/32uNmgBJwPwqkWrVAD7SZLV9Go4QUgsVmwJqkZZv8Db/LtdT+zrYSS229NKF0mrzdVe0gsjxhxUOsh2+TBo7EwcoPeZVawl9DVEy7Rkp1JOtjMg8N02dA6QZFKpQAqJHusffEJ4gb29Ez1tjTdquHmRELwCbYuBYRb901MnhxN7TCfXnemTivCxBoROURcET7mO4alIH9YrdioT9AlUegCn/a2q6k3661ZyL63/POaVX3f1GpejLv5T+7UWNxb3Moj1H9yPO3jJIigJFavqfqSAnYuGt4eT30+mDtyHI2T5FwkcCO3amFcYapaAAo03J0rKBljCMYl34OnDUyvsudf3TkI0nPAzsW43pC755JaaMV0rN+hoHZvQ14rbGBT9LOP6UKj0IRANquafz1nfKYwY4W+eptcqC+K4XEhk4i9S+9LlLH1v+4DCKh6ZEGH2egnv/M6Ff0goMVgMMDIkW5++mHMFqPfYshocjl7WEA4+1EicA88SEBYOvk+uFdaNZpzNnpSMLSq5UZJVLuuiOHrV4g2j6B7fyEsVoIaV8y9jM9NTMqkPnglisSKJbl5zsVwLTRK55ESxWq6i/YUO70IvwtQ2t5G1Gqpek8N6OHfg6Mji0KPk+iQYoH2YVOEYAOupj6cywCPsZa3m+mJ8XF6bQ7u0Cwymm/GotOllBqwCEUuagtvssCFAD7ocK5Ky8uf5JsiqMhun6bObMJm0KczmvMVq3DcMvDJPZrvpEdVoatmQhKPELoMDg0BsQYzABBRzwWu8CWJ9SwJYlodWzxhdzeRHrGZg11BaKdb0TF6uEN9TaSWx6U4feKKsds2MKKLe+3c7fw2sh6EHe8Qbt/NUBqXl7SZyc4chd/Kj0xg8O1D9R2l2UsRbl6p58CHqc4oTS+HCuY4yJ//HrbxLHiJy46KCBAgk58j10vEi5ES7mIWsrjsuIAqT5LIyIqYrMIwB2qGmC4p24I7Em8Cz9IKr4Xaugn+cvHZp87xsnMRgVkHmClrkO5++LA0RSCipL/gO5dfLgwY7jiEffZBxCR3KGOPNtW/ho/8vHYBC2W8OUoef2/Px95kr7cScUJPJ35opjeQ5nO9lfwJplntodWau0Pz+Jg9iydqojPqKXfIXmiUgjtF5DtbTqJrCPp/UOzBDr1lUDc6lduy2NbFt+9rLtM9xDPJoCWFgvlG7AxFeG2CE8bJe2Gx5fpWPVQz7iCKpxRDbWAl1oOKpka5Sl6zZUhlSGgJpSVGB69tcmzJgCBKUSLi9KQXtde7zfgGMr5lxdn4NkjY73csqWNemU/zytW5GEPrqL65fxqsc92N+6bq9FJTIlI95dvsJJt0LXDW1fHUQleiNHFKCbPX0udAVUyjy2ejqd1EJmE7mRzrSUgqMAXpb+OvGrAUcq0CSi09alpWWPcVDgmmdPCd3SEWFDvQcuPSguyYvgoLBZvW2X1wqS0WbcyPnr4ZQEVr3zFSIy004WDacRLAlMDpwGbRDaPtHZtng1Rz2YgkVS40VR3JWiirvXB3/xF5UYtowCOIogNv9x8BHwXioOOb/FjSFkEQrCcZ/H8skDSZGfzupRiWZ9nissxJThjVWfa6S6Fgfnr0mIiirsbkPujsVgDNaLg+HnXh+y4is8SeCTHsfAzIgZcqJ4MqFcNL60GzvbPR5ye/bHTsG/rqGnetcYASuSs9xsyRSM01dLsGRMhpO9Jp6nhWKAZKWG+d0aPdUQwNToU98w7dFpQ7lYIaY3RhWNu+Y06l32X9u2qXMRoy5+ReAI7eYdvmZWJtrgrZ9MakrKhiOJ/r/8633mxYCi2ghnkbiD33Z0nzm1/B+Hlz/VMeXHGenKFwP7l75dZ01THpPGgZWijEm8lDbVu9AbY/3ey0HZTLM9oYv+h4tqMxGvSU2ZgokpZ+D4smnPCUyR82OjbsUmc/igeuXA2M+t/JZqoxjjC47mEPCAV21XBUo2u7WzBKg/yPKDzu+6PsxlTapxZFjUo1GBYZXz7YWFQfERbzyawd9+Vf3Sk2IXUNntTJyA3+KSH6Dc9kVFc2yHzJTLUkmFdckACMnM2DwWkAbqCTnRKnTRDnVhXF6+Gd3+RqllzvF2ua0pAqDEP4h7ZrTi4+6nCIyOUEjxPGxazWtOQr6BCppeOdGmC1f2kNC5Au0HDGrO3yKB4GZs69yB36lWH4Wbf8l0qXwp04dkxDyCxNW5p+eQ5zfaPEN0NNXPZhmqKpdyHPcXRG5iYGYOQilRJCezqO3pLsI88Wlj5YNzAWpZuXhRzwkf+WF9YTlzPMLTIG40SZR4cHd9j0xHv1In0qO9NQCuXiDVzH0klFt6RLSONLaQegWYkS1zssKb643G+dS1F3OlKpfJnczE5803ZOfu5Nzd9E0cfdgy0Wp73YFL/59KQba4Z4eW6oExr793NLiffWIKdyXN7YehO1f6OtIAL7k4jbcGqQsOvBumGP87kntawUdRmHIqBs4egaun09XOSPaAwsqRls9rlJa1+fG6ohNKcHtXdP6zN3xqPYpYSK60tZlMFJ4l4wUGWTBfHLhYqL4O+nsqC8U8xLWXdsRBDnUHICyrvCYYDW7ULzSEQf7hvJL0xXcJSULkDJFo5cb/03/r4d2908/LYJ397++Pce5NRx9Hqa3f9z4UL1a1GJ/XGTqeAoV3L/IjdNp/f43OqzFFwssE82r33astcyHMrt5QYr4uL5i1p+nqPcBsEf9u1KNpWAzT5XzVoTodP1IfvpKwfhg+gW0V+nyUv64T6ISM3qgMma74xjdQ0X09Fnkx8wmxtR9yaLDPjgXLIph+9goDnrZZdHuIjsPaV90sFaNDhvqz0p+f/3WWHhBQnZZYFJTl3ztlSnc7GynCZ08+1s9Z2i7E9VLtdEFf8wD2CvOCrGK89J/PSvXdmrEREenpkfz8pRhSnhEbnPlawdJTvB2v1gAKfaHeWVDjtMBesNr0SDAHcBifN76gKErG/BeuYM/8AKWcPJ5QKcNgoYbegvODUYKogPndCspFsIMjSpIaFW3yIlMyoSu3EX+VryTVLOHzbq7lOX0cCHmRtgH351jYNMAEn3MfFryCAlcpAPxEvtzTln8Rg5vXy5S1tz6Yocv2OcBKY/lwEgL7W54Idi+tOVpf1vXIdwNlmmoPs/+z2eCfJ4hlrMK8C3ciDg8TXub9/H+dNe4BcxwwNUGqQqJ+tc4XasmRrduaRAWb8qSGHW0hwxdoY5y//V2bzUtQaRG3arJzW46Gv1U8Rkf9AIyAQFduzP2qeWD+yyZ0tgAJAkZgsjnx40rlxugfGNYvV/z9KnXXFf3a+AjY7fta0Pd8++oRwRyMoUK0YjGYKtgBCPCReZ4nIgKPilsOo3Ll+91CxFufB/p0OHUGCveiwOisqSIsMURo60X8NXOdvyDxU2QI4Cp33MplPoyADz2pVlHzenjDhrEhlw0v/Ny4f5sbloxHXsyD4z+UQjF54YEOf3fZfXT8R+A6yisdEvpzG5BHoaj8V3U6zamGLlkL2Z8VoX79x85NlksfZ2VHXLd5rFN5/+d3kJ30ISnr9KGZYd837bqJIop6PUPImhmCTvX8juPTw+f2xJNq/Z5kGjmU2+OTbAPdKH8+7frYH05xrNd9j03rZ0pfXn2s51lTUUapFzAwGShRFTCwZCqrO4F/PaFBc6RJPaNc+dR/Amko/dr89t80Jp7kgjgZKar3d509Y5U/auFyMB7uL3uHvhKrmtbUlM31ep2vP3QturOMzkTXcysJZOHVvClfwRqPa3zclrD8k8oM0QLuw5DyZ45xzOMFe1KcU5MPmdpCovj2OIN8rP+4K07dP1ZM76DuCK6nzIW4R2hTiBLAdABUTMMQpXRmwxJWQvQqAWZmjFntGPGQuvEKUd4Q387U72mPbN8Mk1Wsz/iNY9gIkzQ+emlmClXMAAHhhmsfmyNcgSLBRC1jOVjVgOqRYHDFf8814AyJtD47INC4MyeyaFDyj/Uft3kH2xVV6+0T3e+0WyzZ0IM6r6fFyLj5BOV1w7aV+vXroWZBz1DZ/WUeomRuwOxzMHZoPKO7huIzKDPk5vfMVrUtPfHjfaclVPdcm69YJnbhj/cZ/S2eM3SXIiumcadnFN3cvW1R7KDBv9J0M1qFUdaKo5h77ZThcBxsXuXPSbWPAmvX/xPZPfZzZTEtysVjhOn9cX0e/jEvD5FRhvChX1SogEjz9qH8L09rgBpM1FiKgEQsADXvXfuY/5iBMEA2OqBvPv9mo055cLmimirAFpQ3M4TuCYcgqd6n6uX3vbvZu/1g0ZNNTbE+YD30kZcNl79qJ95UVUqH0QtsyNzx5uorit8jlVlI+Pw5wh0UqdYUmGJiogT9UuDUvPJmdgX0C4GPCXF9Nx0Ju9Lz83Ul086b3m9MEq4oOR07tkH6M2meJJ+8HjY4OmA2b5JERgTOWtxzlXBvQKaRyWMZ6GR953Yv9gxRwJ/X/8e5/JmDcA3Uwr5R5Y2pH4W2/eucoPyDYupD+AvF/GbZhwawYfO5zvLvXLllcHIkyKbm5FikKFeWpdpv2VAx8bGB0sW3JAlkci1GwFzHXMLgXYhs4UGgGLSKIoC5IgyDngSKkV5GulIvChAr4fFGXPG/U3yLLzKDqAgMzGA1SmdXQ/wZjE9avSngiOGb3oYxGPdlzI22f+X5ACrS4/OPnyy4I7bfgwEM4IUNNApG1rr6v2AYhaWAO36Dg6Gd8e9/+mffupNElvdH+SBtXJDu9psFO7O2jxUGX+3Hv/KKk1hQLbAYr2gP+7j6nhBXVTXU7TxlvlLV6F/ZCY0XUaod/YplDMP3vsvc3h/5jv2W7R0XUhpXhpbYFG/MYAiiCP1Y+/Ir+rx4Czpm5/PuT9/zRq7vtlg10jIlgLnUsqcyTbANgFZM13Xp2Iju1uB7Wa4/T4XMtuzZv4DKIgajvwTpOkKEyzRH71pri5Ld9YSz1ZKOrEyodn18HjwSPglDOJOlfRb7NvUrQsZ6Lr/NoTZnE12RUx8kFunN1u18Xvb92ClW4JhpoTCdZ044T76wTZQtQe+XzKd8skN48gWdOt4s9gbwVhy7Pr0n6LZRkDH4IKSJFNC5fPPhZvf+QtV+ZYrX3TIN/EdkiKVsYUEiDbsibKHU6vgimI8UARKRFf26CBaWbAA3Q3r1+ooOUNW5FpfyJLa80a/G1T/VKuBuPXDPDzyBPVH3Nq2PJ3zLC/1/ROS1gRfzy2elsBb33yvYehcABU0cLBThQH/TAww/e539OsrzX0dd6YIfDdh5pUBfUCZ7JFXpe/v9N9afWQ3j5IC8Hk8oJNITKMjW5+eThT2qfk88BzP59IxSgXsboCsyu/tX7CLTD7yu6/cb5ysHccBe/ZuBHnUQ91y5/3a+VU423tfjql+omOsHQFS3TJOiIAIiIdlGnQzodvnwOipryEiYY4QkBHkpE8fFapTv8xW9uK9vtO6FM+0OOaiLqW7RyjXC/XNeUeNYBxHpXMkhxPDMAt95H0P+9oERLvPnDgMWV+XhFUWNAP+VXv9/t+qRbzyXgOVcmGgoZ+El1u5yJ5l+kZcWGALs8jMyWl0GSxixfmMCoxSjNHFp4Cfgswh28zB+X5cnNszzFJoknaZ5d+LSYTCV/BH79c/LMQTdvHRIf5hq2BmdzQAeYlFAQVhPZh7fBTnhjbMd/5J8CaiaAQSu+3sUUka+/oSBbs5HYKBCkVacKkUWmjN4eP6LX1kjL8/zYZqw9fN7tCMonTfaP7Pfn91v7l0jfHx+nd4hh9JqugdOrNhX6m1n0Od5f4L9441/xz9y0pDcLDaOrK/c9tleByezR/Bq1Gbz5T3llLFrCzVWFLUFXQpVvxu5F7Z8RWHrnfCvxUekI42clfZYbbUXBlPMjzJN033rr9fPF8t1pI/QH04HHl0HfWyJ8RWZ8//73QvelLY9mKB4Qi2O/CYFqTt6FJ+2t7NrTzKiwX5FANUBYDqwpImLX9caXupL7NukhQcQbhlszSIHvTh6czghbxbiGlEOTO77HdTUW52LBYAC09fU8G57eao3SjP5s6mPwbWvK3HCrGJH4Ha8Pf8+8raFObzawMf41r6OIkH6/XRHX7B08NPUKCQCjpgpoIDif3a0V913McMWj32Lrf87O/7brKrnQPrBNysIcaB6+37tRoL4AmZS1vbPv19IzPlrXnrrURtJFTq5Ei4k6w7d8xyeJ/UwbDEAv88I9BDLW1Hm0iTVURDPGVEFrWBtOkWNlvVedYraTgGSnC8ZzBd9niS26XJqmCNgCbdTeHJ9jnvMAFXBdrp5/lUqx47qARw5vst5us9L+vCiTJWa4OI19BAgnrfHALquMxmV5lIMihz9mSCNZ56HCTh+Y7LfuDNODXZzOq/Ez2E4DcM9+sKx1IgofKXNn625qQdAJXBLvQWlznPdV/k6D36q/lUVdM8hlCNcZ+v0JF7USX3LbpiT6hTuit7xogSWSePgyrHidr03HYFjbAZOAjh/IH/FnCsUlg3YY3A7yPuPi+J3QHCEyYOcjS8MUMeX/Hjb4vaGNxrgKzW2kUB3HYGG3NUC01Qs3DQXzjtSbGH9Znw2fkhk+mvLxk7413lwxbsyDB4QybPQ/+GUH96fZcSX1PaHU/YMf1CD13q4y5qJ8oJvPT/LhI9+eJDesrzTfjA/9az7PP3jsXU6CpZkhoBnrd1hoJHGdyFvnVEq1eCtmbcPIdPAqg6macFoFyN0jDRlXurybSGny8o4RegnnJtX+CAbK+i4Y4qujn5oOxWt9vQLy02aKPOoy9jAuB4/PKxLieE0R4ALJ3bAVWWUjjI53jrk0j44+dfxIgzpVtVQFKcrc0NciM9rRN4vbLf31+gChEFXihrSmBTZAQxdO7cPYIE3vpX9VrAkNbpygmdn0jDDkPPLC9fIIJBfBhptLoV6ZmhudjK7EUE/ErbxmsdJgyrYK4ny5FhdiIybxiWCesHfi3j9drW4CoAPuvUTyhebmBUxreQ3SuI0EOANIZkSNEKN3etM5tnx1WZw58+JCfqNTFasmrO5oneI1Cs7Pe7x6VS/YjyQu9A60dG0Aax50BPDQbH7YJyp+Z3dI7/GmyThuqD+PZ6MvraJlHiSmfkFxXHQk6fbxsFQ6p8ZRuqAIddZR3by8zP5/somAG0ohmwYCegHmVKnNtrYPxH4+eOL6LHmhw9rLIt/XKFAPt++WNYTqmjuWH5lV0Irr9xizM9DkYoBTS4uHMQdd52dIJoVqGvfC6pesKDDBupKT45zHUHqKx/0l+uQ3q3dGvby6jxBljC7rjHak0xRnuPcOJgdhb1YE2W4twPkMyXWUHIqsP9pWtXeHJwPm5LLCiXJPQxawoVaDGxUR4iCEvOLS/M2v+K4laAUFJZDdYXGxWxjnfVy3v56kPwFq1uQI1TxC1nN5d3XU55f1ihoi07x0MpggtU84WcHtMMZT7RqZOscIf2usmiHzFUb5cgcAr2N65LRChfl3s6TO4EHCs5adNDTB3CfN0RXK3HgWhXKvfRFLqw/hXZCougrdzso5cXcdLLk+vPYWUbw0SQ1CnCQc8UUYeeuGFaHKI5a7g78pYPbYSxE+kC5jktP8lFPE/++84SxKsumyN/tn2upJec7AZMdzt8rtkcNYPAHJ1IN+VOzKaWH6k4d/Uq7NpHTeNO7smScH78Ag+Pa27VUrJHHL9fDZ7BjgFHB/szFqdlqkKeDB8lfKCJ9hAkCLuPR7WtEJXrG8hVZad7A+YbD5giSGgRz3lz3IhJZ7aMsXuRdvrJUCuy7+PcVh7DCnK+fgr8DMJBg/U5/r982zvMRvsPfHIcI7lHL1nA+pWL3/oNhyQf4ii23Q1VmOshca7Ltx8VP5AiH8XrGA+S5XWiu93viqULRzbY69vRKhKEiZzN8Yd2iqexcZe4Hq1vHI0ZJQuxVXKUj8RsSP7H5cT7szNj7m7dk6o5ROWNCxUnOB37KIUUi5WJNBpY8bXePa8VvgCfe8amyEQFl/GUK1db51iPIi32oF/BJH7xtmlNebo2Y9mg+I73viBfGYz848u/eLYzl/VTjvMS4+P3fakus/cOb5dfQ/aqRM+kCvtf1syM9uzgXM6vcJNo2425m9Dt9GzE1w58rkYZxDw1+gcY9jL000+wKrtsVYRk8t6q7BzGpihfpQuFfVHPwufitb7Sh8iX6moGGKTuLDeOeFn5FRq+JyKC0nSYKfj51Yyxdq2H4kPfo1707cPBN22adB5kaxSe/HThmXZtjetfndA17uJaYAKvLRkmHLzKbXGIgo0mCDRvxe51HmwpBhPdpoOUFH46/N1MtIyUiG0LnFwWTYGyfA1X+7FIzB+r3LxC34febQke5ceBye0mCbPfe1y4+dz0+KO+VBzvDjHkGtFI19zsdpLGn1YiNr9+LP/xkBLFDFSucigxqyRrCQCiFZw+UJlKj3V7/P2PfseYo0kT7NHePN0vhnQDh0Q4rnPAg4OkvSVX3TNfcmf/Opr8pJ4nMjDwRceIcv8omUO8mCAQlFZiXkBZw6bACnHjBOe8nxgUX50ZcFBs6Q1N3TPEjW50u9NFxdWfLAmyGE/JDqYPgNSD6Bi5NA8L/1+QDYy6BOZVVeripC4+7Dok0SxVmnltSljtba158k6HoV3Cp9uiXgyKDpPZau7vnrE9JchY6m/wznAJe8RhdBnH5GGZQQjmoFhfR/Qx3RbHx3BohuIJl51NLjPu8Xenkct3UWZyDyr2WtrSQZcztk0UaOqLuZAWrkw0A8Q1cVwzyuh8B5VlZ4jG0CIAzDcoBXTrH4gZlo2WinelZ86WIBFrEzLk4KV1ZW/vLO+3fTkcGtNmUrr1rCvcj32pAvuU5zWTcoIvjElvKp47Ct9X4tpoNpjKEAKQCQ7OXl3/tA/FlJ7fMdPPAbyjo47kOWM07cs3pJgZKApULbKrhhJ7auzrQysQR13Tluf54HFhh8z1gyTEAayDEuCLEUo1aR7Xq7r4PkK+SRER19MdYcGCK2zzrLYTieqKzeY+zaGSRcuvH6Pygrxvf8A/PcSzR9Egqdz0IEuHSynowZNl3VUu6q3QcRL92L/DCUmkFt62fs2p288G1cGFthOjeDufOL8cxe05f6NKrCWdRzPerWT1iM/zvnIvsNwx6uj1gGqfAV6U7LD0W8tRcGpBAB3Expiq8Sxlcut9n5FYQAa6Gymycj9t4T2DuZhyIaX1fttIljHJ4TUD3RdI0WLYWKVpvhb/RUP4+A8wZQMshbTD4pptvZ3yQrakcMIdP8maW+NRgJ+YDXA4bSBwz2L2sVvKayRKQUd1anCxwi6cUG4Y74qotyFBmcLfzXtGYna/JoTvYfZE414TiClQxKTzL1Rp/ZnKSHxjlCfgKFrerAtu3nddQRw5F5UmYf1L7PdlgEXv2u9ixroml9mr9NcDvWOBoS/TVgzF95nOun5WUGjUSATxAg6WcWUYITGzk44uMQa9p9k7Bg9TqPQIZDYGMHyrUfWDJ4ToACKUHFq2qIvtV2M8UuBuBo6IAXNuF5DJ8f33sCa2zmU5OJAp785VwJeNi9iRBDSoufgr1iOB1gY9ieeY4oziSdz+/F/43Q9oa6vCy6ELAODeaBamZWKFyx08s/fPcPW0VXDl3TKz9bxWsXSFueeCNewiBxETLvPzT0nI4KSD8Lp/u9sW8OnOeLly/JYLybl4w3OCT/d3aqa2qw3Cmi6CtTcxCrsttpNANYSZDVcbONakqoL3EHfiEvuT51t0TSrcswiPxvmszEMXkTGy7aepafKagyFpb8nbm/RXB57kOelFjJNq1XZu5U+He3oIhGcFfDJNRU5Je9o3lqssT1/F895oIFRiHMRSW/9R0XS8IQHdcJ/XuVNObmYKzrz2wTTpqwocDArSOITsoWTxdgiJaNfhqSvWtKdG7j3/2d0CFqtAaNKrjCuxmB04vbYZUDwJKASXEXYN43WZxZzcfNImfzKq5/WYiSbQLRurMIz9teesio3RP36hMm6lNvu/EQYNgpqXv5VL9RWaLmxeUcT9Tfm6z/ISdGl/yL14NxUIDeKkYeDs7dzqgja3Af4SYulqS/IJIRI4hzOV4fnCIALWX44kEWrF5Afkx+f15L3k7yuoMOMYI2QJnOQudAUyHYhgsDEgH+qDhiWcJoTC3OO+wURXn0gQ0rQmOeotu+LTPQCi0z4y3dM5rrte7wCFhcgdizCLL2uu7jlSnP3g/B3cg6t1ZRw8q10tjeTvBJpkBx9pozYWRxG/FY990kpgW6Q4h7Ypb/V8OHroxVhURzqKj8PV8nq9y4YJO2v5Dz/Qrx594Wp8LDbzPS8/36kZXCyjlw1Fg3sF++3mm1K4G2r2q+IKpX5yJ56vpPn39Pt89SkXH81W6RHd7g7JMc6ZShGPxY8Pdv3kqFEexZaWCKxJL3sg4hFT3rSU1srIud90LEl+jxT2SD2i1v1LPt3oQBubGZbUzZOcXt6h7zzelXAQIDFLHOaGRGUyBWHJAs3vGX8dEodVoAU1d7C2OeAbzGhtwWNYGsU65M41wDMhk9wKBSx9rD1Xvo8YAN/Re+W5/DLlfvT1NmTzwcI5pgnMJy56D/iAaSn2HpFbsAC/t8OhTiljaMh2+L9mvhlfMM/F9TNVzQtI4KeMz91Ps+fJYMBsyyc0Kx0GHDgTLvDRYKHNyj0n5kcguf+bpKmnBAsvyAjwQm6R4Ll7avtNPZWaU8SXG6vClpVszmLyFOzov0SNS93OjUwaYoi6LMPXQaWFkCBStwDybCJ6z3I1m/6pNbQzKBxO9lxMPY5vnwjSYwvEGSst6HwnvPGsfZzLAv+pzz0Ht2nV1uzsZ52Qb/T3CQaG2kHhX5ZOOoPmtko5YUnOc8UBOR2w4nP13H3khYbMS/eAxZQ1kXq87unlHLBfUj591VAu08LVWhnbsn7tYerDlXPZsUkayujQhlRnzHNfYGD6aqOpe4vsVvSoMxQDzmv8s3HUTYNyJ/DqTXE4of5NR1MpisF3XHsmItM9vBYhq5aUIgK/LGY5c94Vwc43t11wm2IxMVK3apddMjuuCMe+1ekVeH3yaEoG7c+8MLvbUzHMPfe2lhHG4mNmPuz22SYgUoJinu8mZNj2zioXsORi7R6DBOG+TCpVQ5/Xjm6II3hZwtxYfkwkmqFpBz3FFDW13ArN5ZLa2aEyVbfk8yPdnlmCCTi898GklXDBZHnwq/Vt3cQpyeEbHHt7J0MscvT+WX5zrGWjB5hsLL/Zq7FSQiiaRlwRauHDwSu1M7Kq65QdSPbMkVAXeZvEMETtJH2cQHZFrZn7RqAXvPloE8AuGvaZR2DLjWfSmgFWg47Kdr99U2qrCE7zFWnADTSXj+k50W4Pa6sL72P1X1FKtGhx3zgYu0YWhdn+qTw7C7eKApyLzYT9/csDpV8UrOBkECzXMUBaVwpYHAO+/jx6KlRqJBD2afMtYApLOlgdZiQ9bfdxvbWHfE+2hJMPFbWAe3cNKbupT4cjOjG4yz3RCJM+czwjUUkwHos1Gcr69YIRxRAtf2rcL+W6/j3yd0f1QzluEVmMeIwaugQPFIO4I0JXHZDDrtryTE4trJ1alnlABm7anffsDMwZ6Yj0IinQJP6MYDs/Co4ZFiYf3+updSIIPORk0dkt7Rp4s1i5NJoLkfaTlMQzRq2cknJjVREkqUxcUOFTv5cuORsUsF2KYe5OFYoBrtSW+K8Wh6wqhvoceHAcgJQZGpwRiBTsx1y2ABi42VHu4Dj1mkTEHbjpk9tGL/6qjJT92JQF3CFU++fxHbz+6HI2Tp/VBkj+ZPFFXY4yY60LtXIwcgVFv47hEfI1OMeHm883/PYPTsWDDB2RKU7VpCfdRCuhIrQhmLmNsb9xLK93pXXRdiF8S8cdDBkCZTO622LSvvAJTvqAOe7fM49rPt/uofsRm5Noz58N6cagGoLsXzI5xzaT7KErOsOW3ewUqN7C9MIO0P0I9mAGHoJnPqxGiNWq919rkuLend5x5J8hSJzDy3Nq9R+ijm5nSG543YzGf0KuVdG+zPAtk7UB/WAD3i5qh5/NP/SM6YwYma9lrKq4YXV+VI01EvroVdFTQRiD95vMF7H5PuOuGem/cv0Zm4O7JyZeSBbhkcnMBvVt0/vKIZxCU2mExf/04o4ot3DjT3zE1tX7wLJHy+mtBNfFQHocAb/SA/uFyGJsv8HbzJNxUheYX44h5XKbFjeO6z9a3GxNyXWoEYywMdAuFkm8ZplPwA+nkzmjyYlAKlEg1Aa1gqWJeFjycN/tfXqjVJzxXb3AsIwF9dt4KZ//MkgXGc1718wnUuYC3nvZruHBF7uvHlspR7vlI4cvlPUF3zQIuw5aDJaBLqbJqD5IVagfKne8r+H0aQbCVYPULG3aPI8tNXd8PHSJ0BgpiEcI7uzLK13JequfNbrwdTYSkCiYQ7XWXzlRt700l2NLj2U3Y+w1RsJM3tVfAOx7OYSKyjPPM7N64ecZz5gPJTcyb+d8cYejivn0wXYP3Hz8H1JD3uxv/w7WBxRjpww77swmL84t+braDx2dQI+Ggpr431kvXGvkBscij6pZBF+D992Tx8MZYjXgqrPE6D491eK9afAnn7btEeNsaDh68VhqAt+fFLqGrT1v7d6TGCluuL23W5TiDDBUagVqTDk3Q9xePcx0wGjoTrNK9dngJucBOMvA2QGdB43lqP3Y3da4emAipN5afukWSLQq/ZyU1vfWYJPFtVbREafWlzVep6hFSKe34L6dhD9RBAOWvGfBuyKXjYy7Az0M98zVNAQyzZGHEJnJ1HIN5ZuP30iNojjiEahss3uORXpbtT5br74tGe8emBCj1hnAQTAjHk6mBRrPNiWgX8mfUyS20BY2NK9aC6J4bs6OApoX+bkosUcUGTvMAP/aMX21DciYkai6ve/OoQPgSZuDuLPsDUCYrcYiOF5l0qaay9c+/Y/6apb58ld16pQ4IhB18Q+n40NKUqu8/94ILfMWDeoxm4Kn+t78DqcADdZ/RDEANKknyOFdn5Z2Mr5pazlQAXKtDNdY4EauEoawMpvRkTfX8/SV+xLyV8Y/OTH319Zpd/JLt1beItWcw7549IYFLMey4VB6p2wt0kO5PTOGb6MS6H/yMs4fyFnjWvdWxvw26bwBkq4JywpjBjPiEe8yVEHcQbMu0LhuF+WD9WntY6FTnOkJnaIadUHwHLL1V8RquREVZuaP3p4M1wMuOOZ7njZkYvpzfqfEd71COH729E7qG/jxNfz91YggQyIXP+e6Hn898cZ6LtbJZ6s/bUR1uYEZbLcpaGIeO5bDl4Kcgtp/p7OdGe6870rztwPOJMVafN7mDv1KEaC9s4ctrueFRzoq057g6eNTlC6vU1pkQF+/bC3zKoW0hioHYxhWB2mVT3fTs33kBLJMUl0epQYwP5QeHcQAcRhtCkp9OlUj5YQrVgI38RD/NZ6NcP9AaeELBuuEfomxvkAJttVrcmDK0IVuw+V+OlqQlPPo7c/mHxqpikWRjFgsgXmFnyP6lnu3vV82KAChQJQMQ32e9/a85j+bVgiQfD+odAtWNizCJAJ07ofWxg/r5+dXbdH4Gvfm4RPVTAbQGCqBpl9DJ754j3wEdxdcGXJFgEtzIR9yT+gBHkGGe8Z0FImKZYeg6pZ1fnOZxj/qlgOiscr7MMHJqdYYTLXQOd5TD/czd6HCWuGZXbp9IZ4urT1tioS4eUegbNgzDxGGC6aoP8DQg7tWXxcCedEeAToB5BWfUEwwRCF40+0VA0Nnc5m3QQaxwb50O/uUBhGrG58tx8gsjP15tZ3gnBgy2e731rjUoDve/HB3+mn6GFMC0pFc70vXgj3ghfXkmL4huHj/0yux6uwH0eocHj9K8EyQoOuEuwp39NUUCahzn2YdHycm0byLS8vXPoTOE8trCi0lPjz5waADaushI2pYMndemA6y7bsWD2AngdinQsC7X3OxupZs2wIG60jWpsqYgW/Mlzud1zT0fl2Pby+L0BJB6vaemDjqTEIlLYr4em3VMC5YB2WKDs2D2BPdw5N2Sv0/tuNILPGJDbnMINWFX1qzmVRgJgeQ5899zrH/PWdwvX2m3Tvj1R/bMXozAuozgH7H50qNlaxZMLOuj2BRKuQMuCELifIsQpnRQSAe8cLsrHZjmG88xTZGdGMmQuOpBr2cyiKS4cBMg4N3Z9KFb1WOdZK2tJJfHfH5mkMRd7qIxcFylYV394lVefSN9Ub0F5AsXhqM7Tr082BQmS1Ud1mtPukbjFuLzl1Yz8ATpR5c3qYelkYvt7APV9svnf7gW/J2V4u8iUGmZK86h/5iCeCMxiKxzBaq2NINdP71ZzLlLVME6709RTj/Mb739G/cK8ixLcl6oM0O6tUCF++iDFSX7GPnSe/Bv3x8vj2ISVHBwvJ96PSPThc5txQI/xORqE9vzPsRx2lYbHi4N6DK9nedE2TSLMCB6qAo7WANLA9For7fpdKksuVAHonEpywEd1LdXQkNp87JaXkoV2Hmo6U93xgJK0890siro91+zLyrHsmzFDgaZvufqIZaza0bo9or/f0/wCb6Vx6VtUFfP+c8Zn9K9fDID1erhL6dbz8Vf+1Nu9FrwatipSujRl32BANPkVeqwC7XxxfT2v4l2ZuvDnasJ+xYDpoQmKFFfaX1VQFhy5wYD6GWydlnmKYPg5/F6dDbKV493RyuKanUJcs2FfOZF6fPhAxPwif6+ZwFARZDhh3kIs/1X5E9e6hmBkT2D811aNFJCSay79JA+wN9ZQDT4BVTA1BA5ryc0gkr3BG1aU76h8fPO7VSkdbZOPCk59Iukl8WtRhNRlvtzfOnCjo/enYKIyg9Cwa/53A0/83KyhFKGzgzV1XqSRvC1rNy/PX2P/bCGoL4VTE4+VUM+/ULo5IB4cf9rxv1vbjfXrg4L4/1nJgamIa9RDPu8+/PvXmFxC5tcYccT9qbrmyu2rkQqC6MMvn91ga0G4Os4vR+Aw8KPRBcXGbaaKIIQILfHcHDB8E8FQiLBGIG5JfGe7MEfP6ZfL1B32LB+ohQJXNY+BPcNruRxV/vaYzrPUGKeezahB5sBxabIR4D/uodzxHD7nhY25XY6w0I2Tr6Ka50A0xRvQ5pQ7p9UP/91YGjDsHQGmaNlgZ5Eo2vKgjq91qDX1Nt5POcBJSP9gLCHkJK4yboxdcJ3qizL0fw6t9KdyMx0roGivzNu6gKA9AKmu8iFH5rNTlSTvvRrIPfyT8vd4SBhkoa1Ibwlf/fdsaEz1QCuScyZVSvQ60xO6v9k1P2xouLwfH2Jhwm2htMweElrMxIJbxbQdPh5Q7Cvq9vbR5buf7kfD4D1Yh0n6NDLSwHCFCvHKPZk8FR8SqKdKJREs/ClP3HFgyCywxvg5C323s1jI2aQ7dSOZ4yKNPgRqXnOH+W70ai7bH10hbZkm7r2ElZwj+0zQ2ia0DJS2yAGjSFwG3mdVxIBtOyhAQFMRr/AFAsJiEE+7ysNJnVqQJ8TFNnUVEuXDdBl4tqIzoYlgmWImyoNE/5dfQkFy8m9+dxTCTAUDc2URYPc05wZuLBuwNFKAiLiYOl7vxGxv9QgzteT1ZvCBv1b1ZybS1GTVpe6dWRrJZAV879mjf826STdRL7DvMp2oT+/13sagMJ4iOntF3eii27qs1Dv2cM/8zqx9RmsYVF0HMcn6E+ROkLChOqAG9G4dxWfJde++jyM9kQZiv6aQ0xz8btl7d4iOj0UCc56eQyYL7qfUJKgGy+BYAPA57l2L9twSGde226XteO2ku4CiUAGdL8ynyZ+I6XX4CgQhYQhqtLZ1W9ADxj2krc7whDhyW7sBZLACYDw0R4oGmTFmclmWAo0rCIAWub3ufV1fVs96usdOZtIVEV9plyFNXO5B0Jhg5Dz1qcn6DTSe7m8h3JiShxji+F1g37tWuIWUjwy6FJFWnOcEhSSYWg5d32yiz1ZGqD4JjS0p6f7imGrH2v8vjrnuZ5lTutnhHzKjtIrhjqY8ljenmb1wTKpbVHKKAdiXkr3nufnauf1K4wq5NfsXewJSrPD8bNtoBV38GKjam9FLyafwIGONRkQoP9Ec+c1RM0gK6Bd7SOmTxJ3YwAlP9L3/tDldyGe2Yiyr8zAqk+hGqIhzU3xpQqKyrTiAUnR/oZqbXjkjXz5kCwPC/SIBRNVOCJytbYqxOTfo4J6C8FEk9Ddh/SHU6bHdCCf8vRSw26fy1VTZbXqpdjklOqJBEI5D9c0NlR8lby7tp+QOJYprYtOBG07Ael26+exlmvU54+6e7ot6It6fJsRaqq2FJjBTO98W5LgQgRaZwesvTmcXl71pmSDaz92h+bRHsTfiewobCnP+5sq5aglenQ4IXBTZldFzWFd3t28fLJxv4ifwWaPmN1M4L6Hu2y2F6aaLEYt7gE89Lk9GyUh8RnpoiQ/MCpk6LiiHOnkGxKcgJD+ixlmvOTrJiBIJydweTbREVK1wSc+d/7LtG1+UaBrZ3ppRNBcDVvNUmU0TeOk67RnxPGGRkI+H1V/HjhEwhieL9Kt6N1a8W2vrNJs5bbzgAYnTB1Sclxwi3J2Aih5jLUvQS/Ze0tkBxpoI4YN4XAf2LzEbLhkfuWuqaU+gApFgtKIKoQrLe6vdD3wDq5ml6V4v15vOWqCCmbYf2bNhVRQhmXm+n3m4lUsSSlxMTz/JUpZN+7cBgnzo54Qf4k7I/d7i/BXN+y8UbVio4GauMM/ejlyyxc7RG87nmy/2EG4xdPoqHMsKnhdJNujR46lsbKmrjGiF3jBCWY8WYIGsBpmm+CeazCjI63wHXCf7A4k9XbQIdazslRduyj7y4v7Brn6+3lH0un//LJTAmFCDcYZOVKt7MDXa1wY8px1CsI8s1NAdhcsbt12EVoy7sxqe8cjI+XBuk5qrut03xfySVGZjXfHEyPncuPFXxP4D5e1HnZQX9NWWvH1oihxOAsYqemmKllAEaoOiXPjdzSY5xdWIgHccmbKsvz8rXztPsbvuIlgueFWbyNo9553yRGMW6zcmUOZ9Sd7jrTKZq+n9QAO0l360IHGnhOZNz6onvFglPg2LFicDblwpmY9Y35QAUfGqxf1dD7sR/WckBtt0Tc5Gmf+oyta8jOzgs1x3ivJQrI5nHPW1aBgppj50dPf68eZBJbPj36fvrVbW+yuWMIuYX6naSeqJd46B3hyeGme966FZOo7UtnBh6Xw/vpygBkHlQJ1VOSER8KHNs5s+Bd/pV4cn0/h+Ntc3kzG6tZIKEondgoN0xCVxb1rNoAIni2RABTdgWodvqkyiqKfYwCpfYMd1D2zToRwoCoxgtGvhZW6hpi1arXP7TYHXS0pXp7kuvIpyCe3CF17I+MzM8xu67ghKnXA2R2+Zr1H7/ZRKbq35AR+yCC1eP3Klqvus7HRInBDRBbmm2cf3t60txar2bxYtXY0MGfToTfoO3Fy8eCC2vt09SP5d2ygP963yxayljayQG9QgGg/XEHOpOkjggE3xq8devqtr8hC+bnN6RWrO+UMgvU723xjETtGISJES0Ew0sz3Ed7l2X6X+5gaPgkqyNb5M10PDeAOkc7cUfcsc8pm556RgCUSNjwKkpjZ9JXeM8H+/TSiNxLLWEJWvvbImAjO5SLjVQm28JMjf3krOijzSJ3FdWnXFxPOMgoEkr3bNWHq3eEnxoJO5vmTnCkRri+lbgS2Xsd9uXJ++NSAl93KVtAAX0ZGXbN5DLczw4huDytvnBAUGm8GQ5TmLY0cYnvt5r8zv5lb97hk7F07G5g/vqcW9eUVhzYQdYO+lbt14JgjVtZyZH1jGNyZW70/zmOKgEo2ZErnvaCCd6Y1COFzhGI4uiCZnmI2RO4RWtfdIJ2Lw/HhPxNcbXjWTTzYsWMwsQqgwdEhsdFasXhNprDu0STkZpBOEiTew7KCgMKwa96/A8zcd3MsqFYDuPdsITYTurmQSuNmD68vJc5QIXf+ccYLiDNkobOr2SkJ+laDT9eAeZ4z2enKgpxn5mBhr7uoub0v3gCz1MfZLElGWIfVsG1uGrgJNaDtTPVC24b/5YeFgx6qllMhduP/rE2bX9o55luPvnV2nDP3sqq6nwa3oaSpPS7NJTB728fvx2X4rZ0J+oYRMkyfWeKOmMzFkHHOKFMh3OVVYC1uLG6zLqqDMvdnCk1GhMDycDzWqgVmfX2zCUJ7MJZxJHZoBvmK1H1lbRpI3l+f0Ky0zjpzlOdTEQygjjRolGYJvDOdmOfNmhWfw5Dzy5FAUbmxKiK+I84T10zf883p7VEV6UJNc6nomCeRvIlr7tLjFkBrcsi9huOWDOfVET/rG4yLiGmOkPT4t1gsIg6YZBfA1QVSkNfv1OwXx3zi35qU6VP0DC4Vilt3rjRg5Tt9vW92FF5zynz7MqSDTOgq2EBBIc9RGKYMVbAXllEfizYj+zNc8Raqr4z0eE40SKXzYBmBvnG74740wgNS/EK4UE6c0RUfkU2tthIOznNxwvcFkP3UwuoorfhguW5dXRizrroM1O7On6DTwIMfAa7J4uQcpJ1kCBnxn3u+kjh+MD1U2mMHGRK3SEq9p761k+f+9bSmeeGTF9Tu6wOqdcYZUc+83OX+7MTzmF+Pw1eviH3hDyvR1R7d6+Fjr0bL99ULFG9r5WIiLdkEGKAsYGxxzud6nreaGcv73Lnh879mBcSPAyEwC47tToJBLeYAXjxZpFc/1/LtXlrm9+5M0X5p9yxsHJPYRiXi23kue7oE4NkVX104gHsYcKbjM7Gy1DfwrwKtQcXcC2vzZEWzMh0KQ9O/asfGkBtotkjx/MFCtUkvyiCa2OWZJ7J7fcLjOmiUJHLPTJ89nCvf7S4bh3wm4HeZDijjdhP+3XruhTRZUCMw3wVgtcNrVDJ8Nd6xE1k9+ex58ditEXY3wXx6vfZg1hPE2HuSi+2VGt8N88xBsZZvh+58Yreb7VqMp9GRGDbDEyjRfaglOL8CqvrDDWNZPhtD/IhOSG62B2VqMEFF6poCWgd4FAR4vNRs3nKfA9OZsPQAegzd7SFnWGzam4kt4yqhUNHfHCsbWpnju6pb56xamUv1h31oHykFiH5L2YdArKsjc//heTr00+0zS4MEMjTqz5uWk6urA/PB6se3O+WtBy4naJDbzyHuLs8iPPTu36yp9z7H0xROgUaE8v256oh8vsGJz8ygg9S72l+9exfRqw5QRiQwSV4A/gAU4PsDUnzHr5LZnwNDCgbPVD7HOtn9Sp65JM1En+/TcN5UA2Ik79W0uwOVNhDToqYHS4rdWebyJ6EgL/YkAmgxfZbfqBPH9wppLMupfRd8XpFj3naIEPtftdcz7t8U4vk+FIEgNKADwVBBT/Ov+PV2Om/+XxXd38+1uyagcFpaWyC1KaASFkoAAMP/UCm5LV8qJX6W/uRyFJ8LLZiZfeaBgA4CeOmMGnLwhtVWfAafTIp7mEDinm8AOw0hQ+D1bLIvOJ53PBlY+Pnu5fM+fGfFh/vrNaPbSlDDrQBAamEeZj1r5n1v88X3Qt0WxnSU/n8q2L/1pUB/pEOqYqL+jAnfygXaIe0/FeM72eV1Xcqa7ek/Yn7nW64g7pEu4jN298lkEZ9CW9L5+t5tE8/4+K5Zsem9DZyEbicCqF8V+btKmMo2dLOFp+gcuRk2+oRKinncQZMar4NXI/9jiv4/5+Ou+ahqMPjjx3wUSCQZ1sDr5f7/qB2eyOl8s49fjD0exu8n5JzDUQczR1f92gZAc57TnK1ZgfvM/ugRZvXFhmJBWPjQV3fTVCAy/eDXhBxGL0j8mACtAaJN4CyabCH1BDN7KygZm5GrjLyIAaS0tAdOrdwAR0uHvo9ZXS51dO5Xj+gBxq2zyhvwDH5vtjGcMGBCQtCgtIPfFA2n5+EH+pdTGHNLHlUNNGehox3BO3wy/Hbs9wB9WHfK8ad83N/J5WQTy+x/uSz/+eyHAsQYt3uy+f6zDvsGddgtsjTU+udzPixOVphPakjsi7VpH3heg9wYobMxmMHzHF01OPbdTkDYUR6fBwT0DL5YR/5GmO46Ll/BgRHPmNEiBx/E24dI2ZKikjD26nM5KpQDtfXxfpz3GJX20fvOQuV3whbHrT6dG3TH4EhErA5LokR5xP5iLbqnfxq1sRN2mOdHQIJ+02wfkxz+tf95WZ75EFMHvvvTk+2/npgT8UBnDIH72ajHn86KQKkEjcUW3/75xKSXlRhnuoxf6wshyBNoBytq4W53gvxOOsVrOBGBx+Qpdu/mvYWdq2i7W+d59el8GEgazVTN4AbqdKEAeBFNUH0wXtIwRHOLLhQROHOh54lM4Q6fo7TrgCM9KG72geXtdfaLH8vV7tg66drOwXzeQTNCXsIa3cuSCaXrX2cY48C2hVlakCn3b/nKK7nyFSSZqF9djf/VvZQlVcEAHNck9qdzrdCboOJMbI19+weTknswrxJLdLsCZeE++kzXxE4+8GDeWZQ9gNefhKSFk8j3nnHGLd/sb5/wfufEN6OUQ0jt4d2XgrGB3koHBfat/6sS8H8up2Ai055/1ALASzHasqnJUCMwmSmOl8f1bunfs/kWpHu4bl06BiWUUGaAEqncmeQfPvMAceTa5TPvyylzxvxBrhSypR/4t5cl3Jxg2pQBe6ojXmlFc3w0P0WN8O8ouFhJvCdRNDiBiv5MSlzm/+eTrhX+RBTiPeNe8o97rgKRE56KG/SC/tkL1V58wJUE8AUEIJCd7ITslapf0fF4Wkqp3s+HV7o7Caqgex7AdBzPt8aehQd6qRu1R1p9eknwIHBtu3i+btjUjPFBBKkKZ4vPIOq7B3+jSQ22K2J9X3HtSX3+xjAamC9d6+d+nH/qAZRNwYwYwbRxiUZj/SRpo7AoI78G0Q0ULeoTwJcPaHC/Vm0tq15V7NVaOykSQqH9+nKkEvqX9l7R5nnu5J1pjwbIGVagtAn6UwfpB8J5MsGWOCN2fkKmM/mNZn1IrnJx4awZOc4zjRMq7rDt5rTI/1iPh+JerET8YYBCw4+YYFgCmHd0lEr5Z43sjaSv2yydRzd5M6LQdelwBwf3Ljq6TZXNXutyA8iChQpY6pAKJtrn7MuPgklADYeUGNsAiu7CLHB468zo1k2SldMTULgd1OCJNG4N3ljQAxRoF+gCSPXMh8pi81s2ZG3bxIwnxI6vyW2XGg7wpGteYSsbW9IAD5Z78f3J6q/qCg6xyjpa3AdH7jS4bsl932kPiU7URMXPBM3BTBrXXwmCOEdLYK1itdFrlA1BuwbpDD7GF2uJjGcp4EvDd4+KIL/1iIoeo7Nt0KoCIov5fTy3KWfdeCSWbgoy+sGDFq7/OR5X6YYx7hl65ohpcSivDYpkJLWI7usvCVAZpRrmUqDZBg/LMgUGivsD7fS21kbQf92kQw8BZvcgQdI/kPt1q/5LtS0y73xS+KAntZqmeURPfW8NVGOKFwGqImAyRX8B2QE0GL0l+DSuFW0P2sJDtyqHVXk1X6tsiJL7bMWtjjXtEF7aggwduBD2tV3RuHHMd+WNo4gF1UREerIigEgtnk97W6pzh5uAm8IBpSl31p5D8GGNNoMA0/H/gLlo9vHFrZwv3TU2cFXBnBm1Byo14PQFOmnO5kGDfANWRQyf13UUqqzEUYjUE39TjaswYJXXjH8UO4ldlJNz1djBhyebBqaIru/zgIQJOO+o8M7e3gs9Dk4fkmlmua1YSv811SWDG5f0saGylT+YoCyjgP4mVJck/5PpycgM91FttgZZ5Bb1KoLs56MROkytCO6zUNVG0GIVTm//zH9nCznPxoxtoPmYvjq7BDz4uSszwFWeDlV9l/fzzfbuzFIWEevtAe3Q4Ay+BlPZ/CZTsR1R8g3jM6DxaX0KNTb+DMVnkK4oOKeI9pz9/fFOM6kDSiTO8zbw2QJFqcmCHf4qnxMYakKuGTlAz92c4PGA0qYDKDlYDH6+MLj3UWTm0BsLUCKb2/xYvuuDzzvdAmkANqKz9cR64quuaP+yUorzHBglW0gLaWreJgispWwff03vFw8C+EMI7s1x+D01LCnoJdfEqw1ERGm1sy7YVXRtcfzMtLIzgqS4A6oxlxVqS72d5ngWLygSGHXTxe8I3Y2OglFI2ltmjpL0BzCCPC8z2/3p2cGm7Np5paqg4jBLWQB4LJ+Q7Fu3wmlLjAHTzweieY4J06BE0L1Welb1obvohYDcoXxIMOsGVINB0qZbru0Pno5e3BmafnPnSRv9Ndx1sbqVg4aQxADCINi67ddNdgkQFpCzfZ20pW6vagSeLmQien6lr0dqG/SAlUdWdp9hK4eqVJ0T+4Mq4v6uEprli+oZ7KWahKULg4cdftRiu+2sm181F/31CeUXFUtRfeI5Yrb77a3Vu8Ijcz2SmHw/EXK3mKiVbJRlktg3LjEFCZWRD1BmvItHeDeWilqKBHbmWvcGOICyoXZAMfPfHcIAbwYwyCzbegEpgj/Pla1ectuc1LtV8nOuuGS4xwYnXjz5D2TA7Bu4Q9HnLDaO11EwFF8O1VYmrokeSQh13s6u1+hG//zAd0oZz1uILL4ZUuu87oSpYAfOBIzxeQL+/egpW4OLuHMtla53XVsvpD/sVLuu6zO3yaGwOuLIFrV4ho1vOss4Rw8NBo4MXUV8XAfi89vHOk+G+Tb4/joZwUcGJ6PYnniEed8ng9Y+axKJGQRFplSAQuDr0Z6bDHlbmGIMiSGJJZ6ax0qGXuc5q2KP43nF0RAy52k8fPQ7LpOb7Sg1orariMY0vcTOpXnCMYS21VgicmqGHVnoWwNYcqQ78/un2cJEfWcASNpUdWlb0l/m9PFSuNKL4BHy2TPjAo9Iwo9PI26UuWUp2cPADVADGzt77dZEn3coOJvdMEWHTZ/RFGt2xe/dt1NooJ83pZgi4RA4qckHtyYqkxgMx2G0G0f6AXFqhz7rr5NcdcRYGf3VSdV4JEHZBwQHEn4Jf72hGPPZ0D58Oy4iFrwBWIlFd0ExIwKkers5cyzhzKOb1qlCRIfyuuAjkugCeXBhOo/RE6OdV8HT216kfNCt48XSPp5gZVtxJT2hfA2Bs3uT/tuH6HXGesznbdz7sbNdDsT6zT0E7sX+iXm58cK8ayeD1BPNv/26KP5wcE9WTCd+Gu9BO2yV7UuF+A3+cbI+9ghVP8pmnhsaovpslUormg7FEZGagNNnDCOkN4YLmq4L4HMNW00M4btl57KO1DIY8ROmuq8M9oQPm9Xyts/DW+K+KrMyzkM37F3dh/scPl5e98iNJv2E3jXWfvgdlJglXT4rnu1Ba5k3V7cMB00BCjiK1XgsgKwuCeq40b136qp0z5tZfZuIXZkVUx+Xvzbf0uM3VjDMDh2hapAhASMGc0ElGQ+G3FX6JBEbevQnP76/szGzIMH6MlI/M6Nr1LHZyA2n9n02MrGTh/dQcYRdU40brG8wreEDHSfFJE/0VYZ7aRSjjtMzCzHjZGfxog60YLRO87ZZKPc4MjfNdb1ToPK/52oNYdhw+YLqW+pF3ScSi2FaWe1KbSvZ2VJIqKfzZ1IOeX6rZb7beQBZ1dLRcQR/MvYpgBkjgaaChDw3FNS7DaXOOt4d31qtGfhNM1xSAVGsyhNAnufTfO7KmPiCevEf+frfdh4PPR5cFSld9HT+rr8t84jzAeq/4Ckxjx9RVrh9Tw74UVqeMch+ME7o+K/2Btnhk/5WDrvGDuLwU61JaT5USF6LE2czX3Kc4N4BTUYsrKiof5L5N4MslVztkQsYOcg7HSny0xXuBUzQ3zIUjpmDesr4/BzQlL5QxvF0AQ+ABmes00KN4+atCYOqHkH10Tied2Q2H5MK9GrFkZ6Ewh7arAgEqIbyvREjYhaRN2a79ps2DK3fcbcDSHzxgwAdrFlcTKJsrJ5PEs9zzEWr5XQiEsMTXRhkW2nKd8twgHD1/bmmMfLJHhos30uEBwtrheKxZY4/pykILbC1LQONUuPtP5sDp/DnU4i86jMtTZ5x3EF8zLv7qNZ4RQ9636iUxeEqwZU7P1aq+QA8Y9K0/FqJcVK4sKp2eEijnXmrYqnVZgxzj1B0lbiAVQ5VBUH9e11S5WDmjBO1CTacAgA6uJvIVsqJVZ+PA//Z83X5jgVOxPZzQX7WjvTbVTsCDDajpph7rz4Yo5hBDzObPhD3pWHUFcJl6TwLVvJ5WIOX3xo+A1x/c0ZcmHW5hN4pt5X3Vn3O2RLYCIhszKygaj73EgcBnb6gQ+YmSYU3Klghtzty14pFZKiL0ATelsHnGVWw6FXm2tAHse8JgjMsRo9IByCFjZufekbAy08DhwSx9IrMOPoOVolAvvqm5Pg+lwZcAEAblqixWTIUt/OEo8RfmivYs+9LMoFFhUSu1s4Py8C5AdJyVl+/Ue0gX3OgVQ+omwqJRwkZTIiVvK81BS3XkuJixr2uloiiQJeGBRGkRpRGnbuOpikKQigf9o8gWoM66p43Zhf+vUauNFcrWD4+kdX9ebbFLwY4x7n6DwY4lH7NdxCiC6pgsge6oreK7MEItwBOThLdmvKLinT5Ufqa3We4qtg3pqVwWkvdUA04hNTrN6wvcAQnziZ3tfoWm3YRIS3VkeCMK1dVGsAhggD7guu621APFd8mQyDI2KC+26S5n+sIkyZAOVjzZjf5tkiSW3x5FpohClfWhGqy2hF1rVpgcHMjveTXfcbkuS7J04IFMX4iby0FHKLXFkcgUYYj9FxzEFpabgNaDOC3IaJHML8ql9tTJwUrsJROHdRurn6VqBnx7iTQ+BDBiVhQramszFtNSR349m5VHZYaR5yl8kuzkNxoxWYm6fNR7IF5vvFK06DwM4s12LU9CVSBDDsopmcInEqFAkElMY3RFSE1UR4CYiFngjZwolHHECA74W7w9X+xW1lQo4UCxh7cH9/zRHC0igckk/WfJx1msa95LjKGuJsKfE9KtoR/YwIY7c/LLP++KaCbDE55fJOhkmdfxys6s0S8f91ZAkqVLDOlprxVhKLTEADjrVkMqmpHI33CCZAkIHoceSjYRmCWlhX8p+TMyJlhnZsbgIBmAV7r/uIphFh0YeegML1GXsN32DBByRvshLixkOPZwScMwc+TvFcFRiOrcm+5M79vNrFw7zJAZI8MmIcysXXgEEbid8Xq40ua1NFUs82X4L601yrmK9gGCKgASQwabEPvDdRmsr7VbBZrjA1CwvOQgRkgWIv42eorOrrbwK2ROUN36aPpGYvVQL/UVVBag8ZXBw1IWZHDHIA7I9tLOFv10hne0UxfkiIe0UU9yrv/if7Y24X+gFHSj++5xhf6AxmP9pJ+3MHkV80TD6Hi/kePyf7qMdmRcdMYSa46udSlBXawaQKzZ/ylmiyXGzAxUibLOR88aoWqy0rmeY9A7944Hl1oKDaZzsgWurNnl24HSgqzumVr2pVN0EGb+7Gsv6azg2SUP2JF9LJpBiM0cY+gGscuSe3OdUXLLIIZjvS7/Xb6rs7g1nJYrXnvmzEOaLygrJN7RTcr908jCc/ABUvYXIpX07pWjFjO9hCpzKBuuAWdp9cuKDwZLOGD2w83B3rziQF04O+kWK14rh34N56qmvu7chSqK1tLUffwxC3SAROC3PCbJ5Rengpm1XNgaJyGCGI+we2wghp7JCmhOa4PXxCiGt3ch/h+yh05mIKcTV6JxEL7WNQ565MmR6Kn/ka2HKejE3Ek03kjnG+hQ++3/+Heds0omt690TD28WdvQASz3fDMVeTP3+fkFwbCVKo/66Cj7BpKrWl8WjcQ4XIToewgyvRu4P4+yVYDjql+uydcLb5er0bWzEoUgR5EeLlslaZA1Xzv3e7FGV2T6C6mwla6WINJetEnvVKwvdW/JkKDd4xnnNSd0jM2ltajFnoqz+00W30rCMY3q6jEeMbG3e9osXwbA94c/QcPgY5ZHNLh9JAHEHr6dYF0okffa55nNtHkn/qtgMbj/Qr8JOb3gqBEI6y30GwC3xCMbjkbS8jUH5tPM5jgdkawjYcUt4A7/04dwGubMAkwHR4vmquyIk8Zy7riXc5PqNrcM+G9qNUyRbq4JStfHdGQJqAoyyizjZ+pnHcGH+3tuFWcHqM3wsSevh53Sa/eSQkqHfvdbn3P66hkQDCjSPONMGq74+Jv/5u2Pd/CGmxsYv+vXvD+dU53wrjFP3rBxZdWpqUQ3T/r8EjxYMBpZGjghdScGATRJE6ZJUIxNOzhInd4sWIrjpXNeVimjtSYcKhZD4g2afzGu7s2zjABPx8eK4Dk8f5HB0wH2D5C7gnhBrEScM1e+BrNtsWJOKuN0OQGqksQTElAEmZS3AFRYMNOfHQT3NoeM4kDNWfxi4lTgH4d2tvOspLfekMxt8n49vJHKpFtDbgrVU4TuDLrPWpqwlsVKHPru121SAi8IBlDhYcGoSMSvrj/YPOwaAn0mcS733QRX5TiRLi/McFXBaakLQLUnlKhVtvyuQ2hw4oAHsAWLDDPcIiIl5a+qjz6vLrclIrXo2mEFoGXWPKzc6P7mU+eEb1lm/L131NBsrTzAHFXJDLfPn9iZu4LM2/44P/z9/hIXrmmAY4lkSyZzOvl9muudsamytn78QTFkpv1cpzC2JKxkBj3M0dnZroAFXLUxw2VO0OnrtTJ63ednn3p5wmXVERPokfwpSPf8kV0n0fCwxTbo+BgDUEPSKalTgKrR4WpYP3qYAfkLg5W/T+6n4xsgwxR6CaF/3PaAWZeZ1S7w/rAQMw/lDFvHRdKFbAhEeJNp2tLv4EaSriUoQDwsCEpYh2c+RWoTe+vGYHC4oyRil6whtK9P785RypQ1L04Oyh1JwDY1W/ErXcXSXB5hLtaMLWzluuAVTRm0gnREmryP9QFQW/za712fP2xXo76AJ9YemPQ3v3z9wyX100JndM0A+ci8AY490UIxeDMyauDJHZdI+lsCuhclSSU+ISH/j7kylY43GvnNM8PWE64/tz61QPRX6GUJnVY+/lFXquhzpuhaxQovKtL9p6bbPbfAUxo1XBeqnDUVl8LqDVN5Bcdez5M+jFc3XfKuBS0tcgdUbM93pJQFl7mpOUndM0vfQZwEaC4hnK/dI73l/n+Q6kUPO3QrhqHFjPgDMHYwUeDSZpSbcVdWeBLVM7zQ0Uu99ZMES5B/5UDr/hCHKWG1IiOJjYF1nW0UFn2f+Q18jcXtANCC9ru8cYPTYsIRMgxiq1P80thiAO8KCrP2vh8jvnFuv76LPSNxgn86QZBWxWFff7xxwl/5VK2WVvuylItu06DbDssJVGzOpTXkjeG8fJd7PMzZTmW0lL0rnpFAryXpTcn2EgY1ZJLoKqG4ucW88wUOkzHTO80nB4fkyYiYnCJ3MiuE3yMeQyrk3k7z+X5AVjLdk8MxtxYTM8W6TIJG8/tYo+x2Wq1zYBxVvaE0+3eW0gMqrXxJSed6yUCJm6jeVKTx5mHVOfVdJDJXARUblafLvWsRHAk2uNuZ5ISTqmEgOxpvBNInAKBlTPjQlHEzyq7D+dZRfZh+lKzA0OZV9xcVzJ51oWreejIFB9I8bxn4PmxUpcZ7PBCTJP6ef8i8Mgx56EDJ+C8x1QJY14nNjKhHOJPQPvr6+fKMOd/5//8+JrcSy+DOa/Rj2ACjdbfP3vr2hvHQMA7sDrzEBasunz+d/7zKt0l4S71hVD77uIGXUwE55v2kzF9D/DxgWJ9LrcHlhiII86U2t76V6qYGSCnAF6CzdfjSx0Mb+otKuO6X16g4uw9FzSa4ZRXdbuPFM9pTandFbU7QPcOUlNToheDgTI4pAkq8wClBLg9ps1nRI2CYrxnvkova474D6hfR/mJShdkyjSoCB6gQ+PYMkmqsfE2ih6rwE5g1YfGM5suIDzJPjQiUE10LTAwESZ/+ttT/sgyLjS3/FiphVY9Fc53lBt1OQTP42s3/fmMXu70OZ+fr2ZSDvwUf3/9Uo051wivh0f6c01sZb99+kT1MCzS7dGFdSuMBLtwnn58jX1Gk/utlWE/qGx15GDNTzixGGgMkzAZelLjfqCNUkt1oIFFuu9AgkXOKIkQgJIS/Mql02BESPFN5qLnxKDl95xKvyODolcBRyOfkQi8HngxU7BM2s6BYhOSE+CzX7LMOlw+xi6PfOk1he71ScD9u5+hosWPvTvP6czoZo49e9zmOtKH50y63KYpw8KwhAXdmBd66adm5m0OeDN/Eni64HEwkDhO7XHHoOmuuReefqFSVUAanAYeBBSHN1zqFhpwxJrbkkPZXoGv0j9W4W8roz2ZR2k8x/yMYH/7PmD4MyfKA5JF+JN9/PGblk28YHul3MxK+2DReYHFXOJEbfXzTI5fWUCLV/72sBc9a4sDzL/DoyxZJiyCzMdpr26icx5Tn+myGeQ3oOOKLPiFwogvly/SOnJ9IGAUbev2hG0YdG7QOf6d1bVr7DT0Bqhwhd22/ZqCBv164MQzbZfPQ8gFTKn6+IXrQgNWQr4l5x6747IgNuR5Y8o5A7CNyYDLGj7Wlg6oLBvdIZPAJPNsvnOCG/iraThAajwikyhxsfK11V5E1S3fDj35LG39iIX3tyVH+oLEofHNgNE0DTr3On0A8rTgp9ZBYxDcNTXsglcdNPQFihH4uuTnlZgH73hmaTVPkHKX1t1s9DK7M5+/zgJ3Vx30DEbyjVEsXnD/iGHiq7+FaqXZgZ5afz9DVuRi3CsS1yFBoL/FOpaxbnU3GS6alc8BE8Gm8lUIGdoW/nahuuSMp4gCXqKwarTOhkMdkZroVd1f8x7a0wS4Qh+LKVbW7DtHeiXHq6/5valdnm65WIGkYBmafOV+PbICzHuGmSPXwTPJg4vdIPGuP0G+pPtqN3SXOm48GSv8QOnH0ueXaJtImZ9Uss/ES7z2sfiwZa6M7NCjcRt/EJ24Xlok4HU/+huNCuMMWVimi0cmgvqoxUoK0DcEhIuXG9UeaE+5nA6QPZmOhDZwZv3ol3h+71v+PGEERlfMeVPNDQ0Bx7laZx4JUPsHSZ6TWjN90OydRlGSjK0XlogslNPZEiToYsP3yNbSwS4WvGUNDiARCwx50JEivU9Mg6LdihDxK8FaI7Dp1WUjCSQLGPYpPv/PGHp9zbGSl6H3kEHh/N9jpXrC2tdgNHc4h4S/32sqx4OT3qwv/gnGFhlvMTdXwResg4IA88QN9bhMhOfRgr9WPVebNrrHfYDk3LsSJNkThH0LQzOA/CXP1hYiozEahmlEnKsgiiak56S7o4Bs88QgixAvjp8BXSYE+xhq71WNhQJVNtVHJp/MQXVs1b7cT1pSzCr95kIb2j0kFgW5X2QG4LM/+DvL8EnMLwpmPAY3W5gReEjQa3NXoUDcsSkA80znFuQIUgg0D4rTFaXCFcAH8auUyHD6F8fgvuHrMRHia9DRc2Vh6agToAV3DF+HmTF4ySDI9eL35yZBH6uwAWEXKaOJZRySAi5LjBiBogJjzOzLF6D7v+AJR3Xk89wpja7edebv67SzxS3hHhV7/F/G3mtdUbXpGj6ab58cNgVEFImSZI8siCRJcvT/XdgrzPU+3dffO6vXbKfKHarGqBpV9bZ//J4GKtuDpkAO+PL2LepNxjyjxa8yMxqvv3UJd1b7uEn5vw0h4e5o/SptML35FMQTKTz9K04l5E2cDuoLHYocGs6kIMxCJJINRyym45Ggw+b+dC448kO9fO7XsEtvUmqqRNG/KIZk07lZSVJ2k0EtOyAOjGGaw9mzm0CarhxpU8v9mKPPs6eXc6nL1L4dcbtoOyg/KXjmkAmIreQfKjyAC7kIF8BVl0dA1PF46Pc5RnaLRSGkt81cCWBNkSkNXUyGsZmYEpo2A71e7lmDcA/iDx+W8GMe+p4KS0J/hy7kOWVGf9WPzgHWYFqfhByTlF5yER9rj3sCr9c3HiZ+nU9DA2PQrJQ3+YNGgURNrAk2bcl+nxusd/aLvZLcjJzUJjRbt1lToDcMns/kQELonsJyOFTGzngycX7NYMOpgDEJZPVMeIkH51dtaZgPV6c+hJbDsl4wCeQS+4Sv5kzl5spiiv8rbDDjgkqTfUY/s/fokdeLT9VxGAxeV9P0wqQ36AZlKterJTKQv3/t3f0Au2DU9leXiwjn4XuvKWJqjwe8oPrmN/gbolcE8g5E3ldT6vSdCdGL3hj7zruNTj+sUfhdQYW5Ke7VZwYzHiajHYNrsdBWBjK0kZ8b1iLNmdzygQ0WWyk3fP7o8u3YB7/D1UV4ts+InGq1Ltur/K/7cYvOrrCMQk015/YHMrFO2tG8fJoT/kZAyS9O1BkfIDOBLqqmE0lPGMzTeWSyViba6SQqqb6agVPtdQKM3k9VHeo4O9j1Z27pcVf1Dl3HVEbzi1GeUpJig8Ct5lTWt3GY8fDkxTXDR10Kl+/tBrVPJ8htNE64m/1KLtd8NEweRCUFWByyRLhu7raPVeze1Dmcj/ZmdG84vIngtjxnBroOV/VOkzdZZ4N+r0tndeMFObyiulLXSqB4o1gQz9kMPI6U28pxmSDxnzPIlfdqciWcFyVEt8uTapZ/rRjZ24i0/jURFYdhxgLIgXcO/qbdtHG6hTre74biDwqPloGfthBv48iTpFvJqXDT+k/HYg5zDmGkzxDy795TGUIlaq/wFNe9m85s853v87UKzI4FXdcvm0OxPu1ZnufVae5I+mdAHveck/gx1x0uqhfVJca7yamILli8tsKVMMFjvkHaVXE5TRlSNQ20OEzMI93893TxxmWEAcVCdGSx2bsGWDCetmVYpEeLfWRWoQkFET3PNmNCkWXBz/MgAGuJEFUOTTZI5MOaYjmcZre6mlzwHNacCSTJSpGJ/R3Ts8AiHV1KDaAvxt+nVzpPi0AORpbWh/t/LHTxtpQD9sbmG88zdMJ9r3eDrjovOYideC01eAx1HFymkgP01VPiOFy8KpUNKn4jH8KuDI8M+UTeR0Z/Xb0H6+m2hfH2jkTsyvzazxdL4nBv9jzWRdy5hKwdClvRmNcy8It8q0hEjL7nGfICb40HhW1d6+fEQ4YtkiFfJTTgkMFAXAXz4Nbz7fHuZ437qI9mZzn3t3YaiGNfEn3qWbWLTsdwvbL8MY0dBqxE2+qXBBmAm9pVPYEYyqdXKA559EN3pgfPZWeWR0fog1l9MDBsf9m8JJM6ojvjYCOF7HRYpzfkc+f8sM5ks10QphtVLJ9qxC37GxhEqdHT4Axhkbtorr1R1u7dm2bHxHCqMZ2yP6gPQP/QQYQHWjPY0UDNLPq89IZ41KBydS+dXbV6MpmpvxBehNXMsDlwS+le4tENd9DluQjiR+lHS53mQe0YpZR8HrN5HEfM03/xwKtXjbU/g1F7z4FnsbLLUj3/n+cHQgKX4iieKR8jNkte/mX9zrIo2sChrDd7Uw8/2W4I3l9/HDFA7eshJ3nMNfdg5yTur5SFPYJoKx+CN06eIh2Klhnq17Y+KB3/sNnplRvN4QWdUgx/aByP0l3tyA0J2bVuq5z5i1hb3avveE/2Yqfs2BCxyrUzl1v1vHCZWJz6wHZ5lV6iK/SgGG5F36oS4yvnR6tW86w+lx5233RwFpMlQPBYHxAPboYZ6sNmAIxUTjrgJqODTkGlknq2UrilhyAjvVAt4sVndMu9Y24znNN0i6eQnxbi3L2bc1wvAivKDOHG27bmhbgouBme9J8A+mcRiIqpn5GvWZJMFRhXiVAdwSpjeGlziUE4b6+bZX11OKqrWaVQk4IrK90z5+zatm+PsmZeypX5NrePi9X+TyS9exvlDHv4ChkqVK1/7dPptkMKiIFl9KU+Se+f79E8feAe6ItH1vIvGyEdAIVzR/vQSstRloL8TZyUh9MGNC93cpEUGXID7imyxdbqNfX9gdWdXR44SMilfJpQTHUPLAZKNjB1C7xkKLlIgtiNPCA6wxmH7rHOORst3hWPWKaJ8DdMpGbmR9/N9fLsfcWq0Ip16ye6e9hp6Q3otQiw1k0q9zRkI8QI98YTjNFsFHdxKqIWUwtZfwiBE6H5nTEuBNksPqzRVliWYco5hlMhtUvrXtnAB6KVOrT6zkwFqvga+9JHM4mnUOOnE9n9+LtoUIFuziqu0EngHrYN92NfABoeCuPSmnba/bx54kEBnmvcBHsP8/3DcWy4MSeMPIlWifVPYYwjQmaHmxhG0Sebvd5gKO9cu25pbFCRnfb1IGDjbEUbNgXD585Fsne23fN2dUo6+n9/1dA+hokcsFi4hbycECdkW1P9VFpjXMMoTw0nt5V6uq6m7E15lR28ssGnvyeLu7AcycXPT2iMbBZcayJhYnNmeKOuUtvBPO2lPPlhpYwKJpGPAeCCPDir1bfzED8NBIv4wKtrI4Z5i+9c/tAclRJA01rqVCI6DNEsv+zNfjifmBdPKyLoD09la5RMmk3iRu2lNndD/N9RIPiZgPB4gZgX4b0iNf+xU28M4bcqUiTS+OlJxeJoXaAL7wV93Ks6Rp4t/9gpVchgp/j8shzPp1I2qmSKDZkX6XyuP02OTf71KlcQe3/7mmqLgyXj/C6SqOyWjecnKGmFt9Mhmm7jyPZj3Hytb8s48dMcbGTY8ftA3/KeUCFHRGH+NpRquaOd2qQ7a7wcU9nqoNBk9lQJddUz950yINJx9vQgNvq+7HvkiiM9HYMAKuXdW9pt3d4ne5vd4LWF4xtx5fMJkFhzaG0FhnPIchh7LwPZ1Ugtl/nuWaSEge4afyRyMeky5UnhHo/a04IgzcdeM71iE1H7QZUGpf6nvdCsJ+zFi4gO84+9QF8PMIEqN9FvUc2vn/lP8UCdiutZ0S6CeX5zBmJveHd2mOl4rSfiKkDVZ/VGdnZApB0RnPsQGg/Btfhj1KDlTl90wiQALmxbNBXzVZner95LLJ92lJiroz9YrZp/Ord3Wjj1fdP66fhXzz1dWKhElHvBHdO9KWYfZq/DB46u2Hplg9cujcDA44E4GEIweaTf7/qJiIHVwLcl1hgxdITuFvo4IM4ydzbs0YWYZS8NHIrTTHPZdrGjfx2J2C7wqLYBCPe1A1ZqAe7T8ZwR15JZw6RUnrPhxOhPS60g81RpYSCh+/W9bUKPRerE2WfsfhxuFhsmrUvgUc7btI0oWOfyIrG8w+jlObNOE1XtJt3vo6vnc414zZLKt9etYUrrX3usHxK4GVuqVMHhP5zp9MAPKZn4te4Achv4j34XWMgRZKqFXSc5l/ToJfz9mSV9SHSrzo5RMR4cf+8H5D6hFZjcbx70staH9dNb6k3MiuGdhcTfWdpuQcA+GzwsOM0dJjY1P0TELH0nBzW48EQE/0CBttfLlaWPuFfvMG3zj2Uc+/htUn1mDHW9D0ovLTVG3m69c9lrOA19T5kMF9HpREUPi0LGU9g8/dYO6qZFfLaFGv9h0mRi74Bj6aRRlTidiC52cpjF3mt8tjLkczCVdAq0hra3EcMfHniy47pPWXgA98GDRIlhsiBBFkmeX4RPMEQ6h1wS3FWyZ6beVYcGo6n3PA/CJucxRvVzrMCUU4GEz1SOlTjHTK49mfyNEGQww1nPKoVHLjZV+WTKrjrXLn/wZpL99WZt17baj/OQfX2ZYl/+48sQayQPgQHxfuS6T/F29SIGd9vVw1JB7+9UN41jTHOh98Jg8qCcTp87uplUEmkp13kpYq9vl5sK667BUJCBcT+RLj6hjPjwmu9xputBTzR9ao1482Kx2je5db5SY2BrOu0G02R9GiqYQGeJffM+MDe6QEwWG8d1KubDzF0iwVMGu+9x51cftLlpnsaTLQvVBo4xdtoUsFQyOFcm+OzjvhAT4E9PKpvRZmdbR2IAcaACDTCM91VQWm9VmVoaMgABdHD1zdFV60sD4364LN/V6L11uNcQTkEocmSGuYwhMo1gFGCUnGZWuLg18WlKO5xWtzdve8URs8rNOo0qOfsD1ceSEW381L0g6i5bppfy0ChibWLPeoRtdurRn8LzXa/s3d9GXWFlbt+V6capGH5gSFmTEYYkvDu7/R+OcHyKU1+7Ohf2o/0Z+/6qrOenw7phy6Z5BXOUjThLb7xg6eytJy0s1uuShTi2hPcP0EDHI7HV+YUlSquz+4S/tW56pB4FFss9wbWj16aaOc4NyQ7o2QUsr6mcCeOC9d/Bnlbp3Y1WMBiALDcAAjGv+ysbWtjnALwK9dDP/Cq3CGFl5elTRP3ZlNnJ8V6Dx3+6/Oo5ufzttsQ6HcW9spIGJnb4jR/b86LYCDnT8O7TZPvjdcNNhi7COH59vv7zuxd5lgrmbF2OO/uM9YnYfCZSoIvpBa+IIH4eK1uTKPoVYTM7Bhf1+vYlCMM1D2xN5qC1piAeEOKhoQPBE+GkKU/KumEbv7RShYQ/Tj8chGOyef4t++vzrzcOoq91lTdlj82JgYhXdIghruJYTFvVwbnlj/Lzzb+DqRM8WM4kRfjZaY86ch6+GWtErKlCCAi2/RP2FUrhUBqXV9SfbsU//34RWvAMZQgmWGi5n0xFe19xIUEP2GzdiFEEg8dwN+Z7+XCHBbG2WTAnkuXyNIeawIwA5BGodRybBaMzoOidaDKHc6Kd0NUwJYb3p+G5UomXEsjqTae9petEBIqHcQRjPBF8RZAf+2TvYCAYpyk+zBORJG9EbM7jcLtKN4MaqhKt9J7zp7jxRKItK0/h6NF81nm4cftmakV61z4qPX7vB6Z1Pnw+t1tfCRiLXo98RmJovKgj+wRT7UzvmEChOCPHt6iKmPpEnz2sIRfqKKtNbumCm+LO3SPCWEAwDVn4WSl6acVJFwGxPnBPzgUUC8jBOWPPvLq30+98GdRfsfa2vQzP2b3HPl0fhjECdQhRpSOBQM37Ml9zoxVflYETbL2F7PwA7R75UtDSY/DSVEHEfr7uWlGVgXE8r9rQETg5xLjgql35CwNcOIQBlM8L10/4VTzE07BuW8WQMmmEmXmqfIVkR7I2tRtXqZjbGGw22+OvbMRJ0U7NrbsKCLJeICBoSo/5/Entrn09VVCwnzHMocrlN7oGscgP7rnq4pmUzp8fcdmcQ6wXIm4hdxz+iySjwwk+v1WrveKWhgL9PAns3jMaogk5D1BUGqnveN2b2/KyOQxWoo8uL6zaCwHKZMinHr8h1o+fOGNGVmh6kOT7dIOO6eLQP5eWH1h+HGc2KCFVwz19RQ4seGMSJD350bNZfH/CSCvPYAWhSttNzWjYEOrUmNwEy5aNOZlBnImXEaPycYniBSh/ha46azpmS0Uw+qnCHEa/IdeDe9nBOyt6rO0WQ754Z9k15zwqdYidE1m8T/ypIghz9HtyOIyj0lTrjsbRI9HUERm0uOLRwQY5THyWbwoEQci4I9KaGzlk35l21jEnwUOoX471t7RGPYwIHB8BjN/mZxyrIHjJPUSJUqtbuPIrYIsnulpSGT3kGh28fRBdV2cNqaimU0ZmExFH91CYy5VTkpd5ZrDf+qru9gDr6oYUYMgz9S/7LNk+RD3vnN9Wy0/ecVwCrOIoNb65/GQEAnZDt7p19toxpehgV179Y5oVlkC8rRs/d0otxHpgxf6oXpV57x11hVoNxGcvz0+C/Hnq2a9xb3ZKMBBHfr/Fx0B+oihpeUPq6k9nt4qoK9XgeLbknoa72FHlZCCQrQWE8h0V3OADexrkSxr4OM/81dGGa/tupVszgH7cwUapDx+bT6Xnd5gJFRK8y7DRjLjcu85W3DXwgVHL89gDv3irUssDFrkn2iniNGnW+4aj35FyRRc+7FKnwW8G2SWzUtn8VIcE3l285HtmAr1DhwKrmk8+z8QQkiTi847IJf1ozPMBLpU/X29lRKtX/+PwLDzFjVwMTbGOx089IWb2oKHkR6g+n/VAidMmkm/bAjiYMwZ9opG/+Y1GSZUS8Bz1OGnag/qBJf/OQBy4nzkYZLDVq1RTESbqMvdxAnQrRbLc0B30as+zfb1zFAn7doco36xTUk/nQiMo0uXImKV8ner6a4vqBiK05Xquf9miWiHXfeCbsc7TjC/tBPUQRj2kGDoCJfLFVcdPKmn0QzuQVqRcMvX1Hq7WepWR91Lug6G2nm7ocv+Zu+mFcOPgetbsRdWUG839nX3nffd8YKt9EN6qhUIO4wWKetZe89ltRr10KWbKWwTJ0Okk5Ai9GySEHcT3lCJSyI0JjKE4jVwjMJj5q+HTfPXrtHIngKuCCJ9yLgytZw8QUxIfJnhh2nnNFQx9Eg7WJefzt0vhMq8wZdf7zTqMu8oghbpmAicQm26Yd3Tw2cUKrscWjmhLuXTsWOpv91KQYS/Hothk7N9aM6ENrZPYMhh38Jbs33b9LD1F4QgYV+eTTNnzPvnwGQ/9yzubwVLfzIqlHtqSHpHXBYCG9smByTQCwpIUNivSw5o3Vz2LFPQvyVqjggk04xwiGNN7SUNLJ4QHAGOE75bCgAdtaDMhFOJIrxfu7vvtdC32yYeiJwVmTdGv8rCQ1TedKRDKvTP0/mEtQOxijcvKmtjCJ7kUyGIU3TNDO43fqic5P45m9YKbGPedf2L7zGskL4LzJbBjj39YsMRYFHMXP/Pc3MuXJMAaV2X6dLlrQB6YOx8hs5Bcb1+vmdr9ULUNgWkvtWp3AwLDFt91mhgjRHp5mzxMgnxo9QWhdgYo5LJs83MRV1uipiYe/XdsdmafgC5FzqA/1MY7eDjhLG+UdhCY2aw8YWqFbL4JR2NtgGuVRrt7Vji+c5Z7lV8W0yhKDqoP9vLRT1seceFzC/memHu+hoDJYEDfaqFmUp59j97MzshEtYM11Bg7MRBCUsZ4W85HaYH5nfQQ5xfT3Di//9xJxRktU3wHSpO9cLKfmXiPw5uO6TsyuNTrGTQqZ8LVN+PwG8WjKJwf98KQh+MT3Y0fKMI8vyDXFdMW9vhPhAq9rYVszZcNNhSn2DBHAT9wvvUuXEV31XYYHrqDUVPzd9qwcwhzHtozMzUnuuUD9empdcDRowRDZ5SB9Lf0urZ40u8qEvmUMj7HT2LrQYaoG8jHokv487Sfv60rVsjLdsxea6Ai09YPp/7hXTzbUR2ZFzAue71YyQt5LxBljbwzcBaZYboYG6L/08hziRvgHZsT8ponpgOTbX0Y8PGqWJOpuQnhNoautaFDZw14Lv54VWu/WX07ICOEPr+m4OkC/tlauYBFgMeOKwh8QBHag9oxUFM/Cb0G6mLkbkybBhEMabSD0YfSHnkeBsjjThoe1U637Ym6xmYRdP1ccmck+JzoCX5PGkO83kW+cG3jZOfPztyk3a6+9YeGh5pi9HeN9tCfdFjWuwdFs8p0zd/hfQ4INveygc0HG2H+nnX60+7LOvs8c9NeJ7QJCPveisKYSvs+vw06eHUAV+awMuxaEIdlk5mRSw1l2zYOgsrlMc8RhgEWmI67ZaWoe/qAHTl7U2vAQ/OqcSfKSZlvksJiUvJbpNp8kerGSmfixzkFtZPR3kzR+IFz3BPkWSBvdiBoLVEco5FqAgzedzIjkcvIKMh2QAeOCt3Hx8DA3fbDIGCGXsQRD7GlGcM0kHVkV5cg8VOKu/4OOa+A8AUs+zvSxSMf1LByiYc1fid48T31oAPL8+bTpUHQVyd8jD17F2YaTWx46dTF1u3hIKv06sq4vTQVblmcifzj//vqpevPGh5rUoItdwd0A/Pc7FgYTVmnCs77V+0lycSBfUwVQrbN8oLwCWzZmWbFb053L4uOZrAk39up1uoRNBV2UzkU6XFwUQF8jJvzeis2BiWSeGOYPfLBLF1xktun0lGJMFvZi4HBoglTt0vUOXTgzTh10crC3NkX2BYOvvybAsWVPAgvQAn8AFn9mWThOgqAu/xpVPuBvBiA5Qv/SsEXv+QxWcq/jXrr4u0w9+Q5J8k6uUPYzknhW9hF/CPLg+yV+5CKeRWDpdxs+0fO9fQ5igdqD5p28XFFzANOJO63i1/LjsqE1EFE6EQdePFh9zIoceRjXu7V7SUT57ixkID429e9dSs8Zg2cIrU69L29Y/UoE2OV5ibymH552mA6T/rGkdWIw1ufRg835LlShKzkKFvo4OG56d2GW4R9643eUFzSP0bBLbfa3RCsCnjoBTTAEetLZGyg/i803+7QYWyKfPfod156IOeBi54J3TMI+hibE+V/dWmW4p5QHv3sPYsIekIG33z+SbGPunta9jHdSfLlGFg6SfKyRx7CPTMdzV0oHWhxqtjCYWNGURAWd5ON62bd41dd1hdcztfqkP4qVtg2B39fXPDwX6+1p0Q+I7WyGvJw3KSQL/I7cHAtoAaSX8eYfMinyi48fq7jiyxs6ZvIzMbZeyNCx1YndmC2E/IYJmeU26gwrt1xFe7nPHUmAt7g7wyfgW5Ubo0EaGhmRlNZGGHTkJgP024Uwb7XF/iSh+PKrd5p9NX20G0rJgVEYJAkM16D9VvhS7AVlt/0DDF5HKc2dr4Dr+QRnORUab41L+PXyFaYO3KboIHhHPC/OmYCUKS1Bi3W/Fh7kn5KGRdf4BqzG/Tv27sWMVuCtiIyaXfD841EPOmxkmTWtdWVPA65zXvG2lczPEmf0Y2BWX3BRLMHyiU8YkYWchZRxPodT8MnrmIWPEvkryqF5T/VXLG0Eg0slzEXH7/4MtlOH3efXPzs4U1T0uO3GL1vuLEn85bqMouRdZyn3jZKAsUZRsUG/hvuKZsIc5LGKiVl/stspt9Htm+gHr3MWLqYhf0vjcMTdOJOcuc0rP0PppCWF57iFqHePjTCPJCBPN15zCJlya6inOncKGSiU9x1yRT4/ZxmRl++GAZ3jmZJDunZNU62lAZwJ3BjaO3XwZPwPJsU5D6boEqatXbQBxi/FF1gFxcySN+X890hyX2KIPHa2kudpycVjitkFqDJlIDTkPYYS/090I6O5WwV73BzwJtvbFthuNvUBENY5ZNdjYgzUwTplHNbe4qcxpg+394nEvkUnstMhjQiCs9BYMq/1E57OfGVoMr5MwBRGvpTPA+elF77Bl3IPA8cXqn9fLNILrrwewdBj6A3NemZZ4hL/Ls/dAVM4SgOx/poeQ7jo+sBCNTG8GEmWKIrrKvL2n57gMTrryoDcc89xMvqYYfiiYkvxIbF8paazeCYa95yyHHTe94E/Q5ebHtlSvb4QNxufDTRjLeV1u/Z0anc+IqJJRpnxlvb3H9VV7yrvkO2wIzEvoCHHQk22VJHpxL5luMQ38tmuWnafLeA0MOyWtgArhdESePpYr5M6C0bL63YU5hy5w1xJ0kC9/hVIKn0+SwsrcrU4FIQuXGRT8YQakRHepJvrD+ya85cJ0BYvqoV92GjUsgH7DF/jp/HMWO2dX5zrEAcGFmpPdNcP09rn1QlkAhhPZcLsrjY2ydxkjOoue3haPTJ9xkPnKHmW2vnPj+4J0loPbTwoUysTF95Mw3qdZYy/CHIGfvMLtw+Cub4YGlebKZealbNRDiWd7S99SuYRwU/wXe7dtU+yRg/oPuLsCX8m3PHA3bLRxfiBZyq3frc6V/8PhdoxRlkQT3PCOL3SyCD6RTrL+RgGJIM/WeQP/PN9huy+O2d3SPsOpTq4uT12f8niqxezMngwwinXz+j8uKuLB8wnZP+mlsvEbHetOtIvFn/+cI/uNXH+fMyjcEQ878mI/IwRUCMX1fTUzMwRmS2D0WeVxrIVXOMN2Fm5mCfZyE9MLQ11jZ6ka66N3tc1HkaTtxR9cH5hrdggIPoPS7D4N2hdhD73P96wvECmlHVf04WJfbWOVNM9wDAc9h+9bbs8IVKzrLI7QnJzA8wh+Vv583gMvEhYImHoMDdxfwc8bg70Iz0Ugcg6cimIIA41aEZAApFoi5ge9c4Gg4Pixy64NW4A64oojBvzExpgbXhn9rU96bVuy0nKU2Pi082+EyWWolDW4vfZlCyCIMohPRmyZeqBGVNzsliO8uWO49QTa+25dg5T1nmP94NQ2ehV3k7DPijyTJlVgyHTJG9nBq5wTRFBlhmOjFw9nuG7aPjh8ueEk6y3TZyk9SuLRbPbncfT+gp3TnfcKf0Xt2VzYbHFbHjxQvCv2f0ave3yWDHnjnTmNo/jZ63llRGnIwNx9tXqwG70A9Djxfve6+Bdo+dTwU6AA3EWmSGrcKNfn6guZGc0fuopLzAENCKYSnnG2v+KU9OywZoFdBn6P7z+iN3JheQV/MZbrOu/8V6vKijZVLCDWt/vnv1vEHFy5V6RlYmrEWNbJQ1qCJ9f2uHTKz5nnYX8xbR0Zi0kRc+DCAzVWmu0fbt7qIeEeu0cc+USub+9tX6TLe2G8ksbiEP2g/1rX/XBFG7gLiOBaOpjAJdVCgO7YPpFL0cHr147lQ+HC4uOw4MMw0wK6eRHlY/Bq6nPIJYQMZZ7bgNK1TrzeMQyxjtoe4wJmxrd+CTi6g+kPXr3r19xrJ35dQbPT4g7DRz7V3tBh6g9d5hDbc+Q6R5LjeEoLQTR3ptp7kv2yBYcdgEs1qtm2znnwfV7xF8TLZX71CFiSQs9L2JvQHnh8nRZPvhV+jHkXYKImOddGEfLCYDu0Vsunjbih70rUf3ETGNoIR+Ir80JOJQ9pkhBJavklisKRB7cKUbTKxmQ2QCWS/jzFwgqbR5yIff6VaOp8flkOMs4CmWvb2MK/LBv1rs0rL1HwWSfLCAM4pXXVb+pIZBPytK4OO3ZWGPUbEcznCfLP1KJvOOFZaOjiTPlheEH03jJbhtAHBK8RjEfzb6jXWb39TbjPZWY8kVT/M89G4mSUoFo/cX3FwP6llB8K6ZcYab22T0Ul6qwxqwbV8bQ/xy9IbqE+Xhlzi5GJLYwyzyFj+1maEGty4EEGKor8+Qo8PaPz+Rrr2MaA005xjgaYyuI3JDuU1/Oz83DWMWkdLeeZisyEf+MNUP/+qnjPNXjdyHrc9qxUTTY+ogReEc0bEiJRf+zmBieTxud386M7P6eXYs5Giyin9vn9RorhL/QJivmwGznLwkRm54ygV3j6vrF0Pn3/Wh5zyQDvKvlzl6dEW/iK8b5W4hbRe/1ye1mnsUzwxxktzAKn7svQxx1+FemZ39hzMCMe3sjqjuOg6l1Jz5A1bkvdgS/QqzJuXps3fpSw33KNhW/IRpHMfBFbs8Zm53sltSY/aH2zP9PKwEv/XPPT+ZVZra+JW7+UT9RuZ+7Jl9pSv8GgXAxk/EzBw9e/sLcQonY+hwz6O4YE9Fie1A972ldn4HsLXz5Yj0Roli+/zA0wjk6wJiklv/mVXp1uuDfUMQuEyC+uBZch6QMkYMdqemgXdh/S3Unjcfl90BNNcv/OG0GMgv6gOrTl0M9XMuP1wuTsHcE/VYKyaikCcEKAeoUeyPHnhevYZ6n3HgkEWHXIr8qt685SnCgkWhQobQSbFs3/EcVOt56hl6bqzWRTe+68wxICHo2Boi9/tqaUFoRYhUf4jHJrk/FWjfPMewLVJgL39Wq73KXQ0QZpePaknakSkmIxh2dY51WaGP9ljOlNotjUMz2tBUu9LyNGA0uo8HVVOrkWQHC9niyuv7KER2Q2rxTvD8045pZ8JOiC623BKGQ8ljZhcf4FT+o3b4VJVecpe689u7jDe1DYb0qwdjhI5pfXKs0OJVK8bKY+Ah/6eWnb1hVULEaffkU5D+w0qdpFpDfoCiYp1jjF81J2SsgXfIs56qP3/TSO3aR/fAQ0DFJIi3jK5TDfUR8kh7mlzWR02xodcsEbsec8YCGIcmP11rE9yI9PuBeQfX54d8+8X7Kr2CjAvn9A4cR1ZIxuK+t/8kNfscqE/TvP+swn0Au5Pr/s7/sMs31bojLyop2qHB/3gXoZZnjzud66U5hwdtcTnI61yHQO1+TZ153ga/pGFi70QNIrJwvho6NPG1GLKQkSRu3z29dizvKOt4UterdzSrflgRV2eIoAY8ohvq9SQ8E4+oYdLTrru42gh8DcPiJabP3wNF+cqWZeWFIE0vPqQh9xbcm5beUHux44fgQ0NNNXf9BXBnCTqOsHHft5jUQ7Qhdhb11Aytp6VcCoZEKxZSIObYCxo/s1qE+gjOgMx4iUeCZ1WOxbjInwRxi0X6e3QX2XfccZdE6o6Ly03oHuWAhZnPxxgR4s7q3I/a1B8+9xt0eZEjXhDM67h7vcvqEURO/dU+JBB1Ht5ETTcmKFiFu/c2xwKdsPf6Hvf+CHFD+lLwDK71Z+EU0BLgY8NTlfxfXeG/907ubpADvhHddv33fp4V/1geKKIePoGQ/PHGHrVSAq2MKUo34XDRj6bonQX3VUmcKxpk+CaydlA/ml/7CybnDn8dwIuicxvq70ZXqsfaUe4zBfvkk7csKIt3L4WeUkEMDAQuWIznDcuy/NhgF/bb1weS5jFbgkUc65W/1Y6bPEx8d4k5SWO+ISNgnvJ4bkLnRglUhIPGwmyBkUCeLPfzWCymfkP4WCc+7DS0zCXbusK9em4guLariDCKxk7zFuv968MteFcdi6FL0ROFoXn3PsmnRpuyXLR2QIeWN1/S4XFq84ibxDY3LhU+x/QOIqsP8iNK42Bj7SgrY6jQJDNMQyq5vc1jko38lbb8ZfABqaVTN5rkRmlGwVNj6p2V+LVdXsTgeU3yeWb4kZj7jRnMcFfhpjKO7XnHK0uXg3Y9cnnjJpZvfLsKxidsKPRp77HCt5LE+IL1Rl4pu8R/6n1xaCdLETFKs5OfecrDGfKU8vv4xA7u/zwPYvkq8HDGMGAVXPsb9Ys5phRnbIfC0nRkVJ56zkIf84V6WxefC24Tczsg0Az4JJ9UynsOj70hT14te53uLccdVxYqPNEDCcPyelEz/0V+9olaN3THYObMyxrAUXZGsCi7KnsupyZk/Rm/EXg/veg9fSKOo3EFPnjXnRUyoI/uTcZMGLwJYsybof48XDARUc/XJSjQ4/no1Mxx70VHEvliBFecn3qfwjKBxIGKOXurLaVY7rpdShV/OXuP0VcHsk6jPrZwOL00WDwlq925ZbGuewXC+zf3DJGfBXlWyA58ntQi/UBQ0t7VBGeCg/Y/0ZFztjhZkv+JXPpLVN2t4mv3pz2H7NjpwyieYic8M0eTclpRdXSASIY/NVbUvRAMdFXxxb4m7rbniurJgMDMqN4/Xe0JNffPdFu8dxA89lXW7nMCH2OHmp5cAc8qTbfPqW6ww2/8z/lwOQpbXf3sG2IJB/Ts2OuOeMj5R9ZHsES/844qXi7l07OdQ5Za0MqgsJXjFqSScEcLcW4hc1mEpvE8YXUK1FtbyOzNNbgkCd8WHl3o9dNJ7s9uRgrund7PGck+satcQZuwBw6ZtLNb3LBPjA6KremWLv/OHjJFYSkqLi3FT02G9rAKQ+5PT663/lN96Fji3vvxTuprWktZ66KdcXtK4VlfOWHBKbsnt9PB0s/AgcHa24S1mUeISRyH02zsMTfn/Qa/Q15pfiIOUj5vVBKVbX+oPDx15QlagwingcYY73Kfgku99+Kfg32AeAO6AL8gMJ9c0o9j763Li6P7vxkUBTnfFbiy8JKof+eDrQLxsDJ8to+fak7LeEMXGUgrOa9CWIqnB5hCviD6cqyKo3D7qiD918lPk0ITguAMdf+SJR/2ziCVLVeB9tvcNfc4oNVtM44r2h+ra7S7pnVeeOXn00jnF/DAeLWxx66G2j3hmTu1VYH+/3K7g1c7du9l6t4B6D4y//Q8Wtbtmkj507VNk9KV/3kKzqcjRGGdvIgL41j9W9F6vB2oVydewq74T4yOR7jXwWnqkJ0vggvs5xbiiLtjoAazQQ1Woy1rlKNwqPlV8ihYNUcwCxZ7WPj2PP4GkTmqBTEQpdHOjP5j3fq9YJQ6Vbz5WH+eZ1GY4R5xWHixlqLAjgVUsumCqjHfjOtZhJr+WLu2cU0dRWWrH6F8Oh5L7KCgla5IXhKo39iz8wR3kvK5w+OHrtU+wg5YZRTdf1RKlthcoJ1Ko/xA9gdLPsuPgkpPs31Hnlh3i+ddX6BWUvL3SkmyY07e4he3MRhaSxIW9n7G3l5aQtb4ikOe6xnwATYifFL8vvYm+Z7kZng8u3/bo/Brj7I8iYx/2yNM3dckri/T9d+9j0p4rEt/7/2o+FPs6+9qw70/jU5bqnX5Ycmjrzqbl3V0keQfO2zDXbo2GuIPP3D7Hv1SFF1gceHPuP17i6ivAiThMHP+jwKkgj0zqV1q+NsKSvjZ+K118e9ZVv7/eV73u27X1Li2evGDj1RgE/S2v37+EN3+Vm4eL0A7hvlV6H/ol/L3z2QpgbNS7o0cGAH7sfM57PwUK8hE/YnvQt7Lh/tMlMM7EKw/o+RvBx7Vg04g59pQdPz0Q2d7sWDVTol3DP/YlUyAYWHoBHIuhAll6/TnV39/Vn4Z+LpBZIn/UaOqleavPiNSdfv9ffh1xsV2P+OPkLhCq4bfcsm/9uX83Okgdn9VxI99UfWdX5TFpJbG//hdwcqKv2sKy5InR736ZVtP+6konSMXnN19cIhQMKla8Pyl3ysbdTk+zB+ze93Pv79l4gK46fJ68afyX+rXi+zDSt1Cml5/qGX/+vdjXHyBDvH9z5pjmZmn6aptYRGp39e80HuUt7Cb1qkVJmZWTONgK8HdayBOUh0AK9lrX9ke9pvTIhbzN6IZ5NI5+mEHkgX64GwTewj+J+KLzzYnH+QlPX1e3vm031cVOWxh1Sn/UJ3RX+fH+YNZi4MOuhrpXrlH7hFJ1uSHGna/jdYdvjF4KnKsy09/x4G/25x3J/23xl2uD/rH6buG4j9Dk0D1liKNxPWyIPZz3WOusBrqOYxcXHOi1ziI/DkYo5lIPsQcsJwBfc3C2QFKnTiMlh3e2I27TA/xjg4QfXLAr6NL5YF1HGVdWpe2bWUPNB+OYcSz+PjWTGJeeUiVE/me+D/EFG/fu9JtAP34H/Eq7aGDh4qfrPsjWol2i0ArGZsvfUvwaUB7ABHcaZ4xFRFUotTy7i4siPOrdDqt8d5v7Duu6GRCSJs6SyvFlYhUcu9ALtdsdIjZH8TmHKa3ae2HAZcRG4139UnvJVkDkSwqSfKc2Wb8fAbdu9x8utzrWL1chRg3Qt+rm1hhsw/ARPMUqLI67XMdivneRHeC1RLF7jSMozAXrd/3hHyey7YN5cEr8qeG0clDT9CFEu6HvVslqIywVM0TwNGn/g3CdNf+nTe5IHanHEnxKumnH/+OsM18SP4T7S8eRXA2qoGGrhgYMzqXeKjeMFKqV8tWPM3BUMDILmH2N8iPLmqlQqMQmbvPIzaLgfqw3gGr9LLYMlMwkBHRc7OhDASrN7uOsmfyhdHV+saZu5QE+v/EIT95Jvl+c3yE7IGhOzbIKQlJ0putX7frhWP32hPbNng3M5UKf7PAdjgOYzCH+yUMy8OYAcSM9mjesrw5jpjfnmcZXeIDKLW1b9et51FjP+/DrFg1fi+6224JafFtB9c0VtvV+q3vKHzrAvuDN8vmF/9SAavA4DLHmA3t9K8OcegzZSFpafT8z49wvB4sKjeHj8FwIog2BfJOJYbCesWtYPR0aqy1+HjXenRWmAnunKp47LF0z1Y1BhcoOcdpmqm/IjkSX7K8dzJ8VEP7xWfdjs9il+b7UY0OObBuZPMsyBT5jfaapLvY3fJiCUxeuslU0AybTXG/PTl0gWjbiZjORvJv9Hyu0UXe62jFgCl/+PODjxhToujoUkRm+jZJ2waG6r1ub9m2ElN5rF/6O72aNzpL6FF1t0pf39gZWUcRfmjMpsKuf3UGyoJDZWbvfE+x5tBITh7x1HEFFdcmf/DUfmpk5xqPp6eTzfmkRHQ2h91n+0cruNEYy7OpNxeJhrwWAVMG4caM86S2RU9VNsJTixr6h9HT6amPZZqo9o6tf7N19D+PXdeQjxs7EAa6BQReYxqTGrCBTn+EHnaPrd7oumIJ756YQbW0Ahm8rmevct+vhbrr7097lT+Utj8JKQGfdY9DbfT3pc+8OnQ7J4JvNg0sQ63UHi1iNOYBulr0waf3Vysv549pRtYI3bET+hCYdf7JYmUmWOHJ4Sok6yNjjc6xjawb6w/Dr05B5vXaywSP6Z9WSfBqRJd6ma5yyelBQGLe5tcqbS0x9KXau8Zlo8menSRgIeZBkmQz732KdC++c6lBhFs30D1rXnSJp3tJ7Ye7yr4iTj2KmSIUS2rHm3Xyg3wkRl+jo4p0tqc/33OsN3jeP7NV+Fx2pOd+MX7dPepmeoueiqe3+Wk8FSKJv/3ZnEPUG6Gnl8rvsOTlCP6i/NDdbP8bE91sF+H+wX35efIDfZTY1Gqi/2BTSIwaZ3meDrtuZbErFxdMlYCxQzlUenaBlz7Mu0vRMB/3/Fnzedz6SPVurKed99mgdgTTIMSibFUj7tOmTI1GKPEWE2sZ59eboZz3boPlSKP9NKuyayEYrkCfRmsE04ZratBnpgCbPW90WpEeFiCDh7uupu99Paaax4d7BvvzVTCOmabixH1w9vjm6LTqtR+OBB0H3fHltMOxdYy+/hacph0jNnan7BVQylx/Os8Pf3Vpy/chKHLZFL0z9BN752ljIsT3qWdAYgN29lK44gX7hO7wfJMzGfdTlazO0ZMwWdvQHiNb/8Tmi/zYcLOZDhpP5ZXLP+4eeaWxeDOJyjrrU1OoZdAipGY35G2+vkB2M5ML5RpDN2c2AaPg5bzZoB80c0g8egZzcTtSzMRZe5Ya+rdCn6+a3T5+EV0YyYz7hSCqYosQgl2U/Zba3qH7/J537IWswLjRaVnsf+PLm23BaTn6fq79OC09VroaK7j34+RfD4U4dfQbpFvCCoUTWDb4DLrCpeRxkLdHT4zOVu4NOdaOYme150ww7aXlmKuw2Xg1RzwCJLehVxjx4TB9d2acz0j2LaXY/3T6fM0x/urLYAih0w7r9XwWx2/I5NATyfLTRDpM5/U2M25z02zrHKfQB1Ksg9P3DYKRaZpjKchE8w6h5YMM/9BTjKKoqf5aozSZWS7zZ6Vgz2oSwer7VeNZbKu2Ynvv4wg79S9FE7wzjjb7lVTnA2VPr5VK8DgC88iP88y/8PDNJJGPvYN4QGY1eNvRFPWQaUIogDaaaguvhDuAtKuLuLt0gLnzrNmS8fhZsuZyA0R6xfImi0UKEU3RGxovsbxudlaaezvceO6GyQ5SScN0LHOJnloUsAwQedv30gDzLA8wmOYbG/MWeFYYAOf2tvN7fI9+7Yvw80Y6lz8RPLCsfHgNB+U/cSVRvNggQGq/eerxKLaHBo94zxRMicT8Ur6CohgL++hU7POC5l0CKZyahqRUkty26u7GciC4XCBWHtBfrV6fW4i8B0yo/aVSgk3sJ16pHsv9+lHHaI+UKyI2DneCUXpTgXghcmUhyY5LOHgsAwpmYnhmiuwX+Hn03dplxQd5+jBr+Cb2cPEktlfllg3EPTI3gJD+y8RbdQH6KRgPm/e8SPZG1Xb6nvAm4np4NNJIXpuB4qEzWia1UN9MdYnqPxsXdOg+wSAUCy0R4T0yDuFjdZhGwYmwY09SMCJLpjjEbAJQaV+9x8Jrt8uRP+G5EASf94D4D1ig9T6NztIeSRZncZwaTKEnuTw7e+XDU+X76dSmfocHC5++thCX2flvnCW63yq8yxtLc/+oQyMHAkHUeGGXAeSufbj4cbjl0PmESEjYuPh5/UzdKzvhA07Wqb8Ftwmunz6EwUxk4yt78W+lik+PJ4LsMzzo4yxz63byYN1p0ZxuG3jKzzNP+CbhcR3UaGTWIYe4Z8bOPDwWrEk0Vfyn+YN+Ij970EJQEBhgLbN+jVsQv8t8tlryf+IdN9DV+0W/2cJP9PZ5Xg76RjLtZznhGiKK2gLlyy0DndipYDNnBOS2d+WSgntwRbD6amE1K6W6eSddyBflWkDYssuTjGnW84jH3kQp1vm6Hcp3dB14p9NpMpvIZFalqsV5dROWyikYc31hKx6nLg+nNNRm87VEo9/3B64DAEXPUJeCfGUQc+sKuvRNce78SNAJl/fpHj0n4ztvqP0hun6005YbTPk6EuxIxuQ7VbCcG2FEvdjHmXw8jJFS9AhSc5FKjFHflPV98eyCmtH1I1x1NIRhrpaU3efQKMED1CN7sr/gZHvDitOcnWc+ZN0pgNyk4H1inFoGGfph634fyH9FSi6IIGiK7XYwdFoYy+gwH3MuV8w8V5rH8/IR35LMEClDphLFQ5i8eSXsluc0f/EQTZloXNvIJml1p5tNQBUrpKKF97MLwDbwY7gNkJsGd1gHNPjbJkaEu3yNjS15c0tzGXGKx0pd8YXY5yW7yE682XnqpsDopvuLDckhJc3UsUgjhDdN0WclkGpYfx+PeoLKQ7JCjkNXzv0H93eHNzBGxqCk/0ZfLGE5mppD01xvG9EykMwtvkGOmLgqGKVWBhYc5WOMWLLdY0z/8IWw/mAmzF04HG6uLXgG6UCzEEG2Sf8mYEnjXEoLayjOkDvP84MREaSu+9Q4dIsOzZm3p2Vn55s1PgLRUwRLpb7WEarVx5LjDaE1jwrLcUnq/5oGd2vGCtnTdQ4bn5QxUOek9UdYT2uNRRfZe6ezb+ZFdO0bAUfYqwcQSA7opPEijq3ch9v1Q4Gh1fVXSbRKA028DFMq+0KdCAxPEjaFLgl7ThAmSAhQgcTH4kI+ze46Ag5PMuMkcVOmQ6nbgPfExtPbCU8POvt5Nm/qlw7keLD3Du/3y6U9jPI1lZJ2b2y/tLEu5UN9Jj4tIh64GbYxf5rbZC/JLxpaF+9srwCVfNIvDXpJQ82Q8HmniT7nvI18RW/yT8x5/t7y2HcLOood3o/kUPzgrh7w5XFuXey/J+jY5Xetd9XgLu29eeE4a/Qb2XQy1rxlvEgUy9x6OdJS9XpoX6H3+IzXrer1j0yy6eC9WKVxI/1E5C7ceE8fPSKXAnO9vwOfJpmi16IXwXyaKhz958D6Lwh7CKt33mQba2CmRV2mUEOAR7uSZ7Y7Bs4FQJZWfVCJZ1d+ialPZsjMmGMSI41fMBNyvre8ypW9We2K5MFGVEUBRUI85oiWrSCeFqhfqp1B4B9et46Sgrx5Mgd4l+t6op6DN72zs0QIreAqF5Ys0p4vNwLVVHagvcK/4vOW+Nxj4l36KgcGe7QGp/IOFjG1zoOyvF/pIbQZ0/TmOcEJEoCMQEvjNCu7XpqRHtuGp9s5gNoxv27t6xwzcfBw4GFr8vhs4SiErG6SeuZAPOe3eQX60MK9nkhJ/3fvieIGeURawwhq8X56mqMo+hXCOcEoo/tFX3Rzmdh0onQ/qY8FqDySQVP7kruwfeR4ZzbV1JYfJsj/G8r1edN3EhWM+9Rl6NYvEypehh+qr6JmLpazTJDjrXMJzMP+6WNVdT204FOaLnn7F9lQBnRpCz4jQzfk1edt55e54LE3bdjKXlMn8TH1ztJ1URc45Xpu7Cl97bn6PgAo9emSFeGh/Klexf7IVGz+q65oX2Nws3CvKdXsFWelMDl8o9dCvhNZBJbv6X2CwK74OXsa/CorPBZmGWcCTr63bvhTLafBkob3K7ieu7EUz3TPSapd42FAxvXWsViQ5yym1E6Hqy/n8Wt2dR0jF+k1kUkiivWPfweO80T+3UP+XRVrQVk9B5RyDwziIlqv+tNGun/MOLTKN07M8pul/dBqKxxUnG0hV19vp59x4lMhPJzDHnmgEuH29IhRl9StmqAv3btpWP6FuZLs3T3Nk/KGNdrjSbqKYhffAz4/eBfBCSr6vvf1GrJTtZG/Jv+NiByPjN4EGOke6DSK4ssVIV3bXAMDOu0KHAvCPZxcUij+3/tkyxTtXvA09kd6asaMeDgEzyi1oJenBRlkWL4hTqoGul+8Q9hnwc1rygzcDYcWNIELhSySv9BCBqBqu78zKN1JfecziRHsKbJRGZ/NrhqBXFyOY+/W3wdzDaHib9sHt+TjClQC86rq7uFX/I4Puk0oWqD1/tWb3vHSp2uLKJqXiFtq5Q0ElMHDhef6xnx4ABc+qUU1HrWpnfARt6iXZt576nwej71YjJMG7yL9paZuLxbocX3shQ7AAt1fnnAxQtGGdubyQJecJ8H1Z9LnjBX++8/ZH/O4Z3/I4fUIf2R/rjTMQnlV/NV/nn+cnbN5FMfYx6kcbRKhQK91wj0+HAuhsLYhEUTiTIemuNKivhX2mleIOssb/atCvv9c9bR5EePbaPW6d6UIom6Yd0FT7lVmAOT28EuRN4nLXF8cd9WNRno8KrQTAZ8GemasvwK68wMEQ/y7b5s+7fNSbbeOgBkQPk9iW/gmX1F74t0TuneDt2lhFPrL8qAJ6GoDXsEzcl4fWHz4akjEAVFMyzmPrmtJBOgMKehYv+wAHX3ercy85OB9JiIqPU/BRsaM/+o4fWjDb8YPZDaypcMkD/8JVllQTatX2PulZjm7EtG7PP+cWz4tvwxdLq7l0JIrWZnskBKm0f23B8xtgRgsfokj5v9W6xoWgg5A2uha5Ndo5PjUC9JSJ2zXk11TuO0djeJwZlK1vqZoN8dyKUy2knNO6IljE5W/qpSf6IDIdlM1oGc4PATzs8VMRKSuAWGD170Pjt0fc9dFZoH+W0c8dEv+HRk/fiPjuj5psvV/UITY6uWuZYIMAofxLc4wqeAilvjC4jljvR5P4aTPK32zi2YI9Si5NU7/HC8JIXL7+b5+s5s4XdcBWhpMRjbMjQeRuvea0eGn8U38KdP07cq0xwDMQDrffj4VZPmyjSAPx//EADjxAe00xbcfmyelqceXFqvOJIFe+4FHnWc7xPRFo48YxpaEpjk/n9ulWM+47XuPc3TNzuF5e0TrRZfwdfe3kBdIkL0unRtUhj2vzPE+52gdftUYM+VbhbjK/9feey05iqztwlezDncE3hxiBEh4hBDSGd5IgPCIq/8zUXVPVc90zbc6akf8O76aWBErWyVhMl/zvL4bH+i/dLvcH3gdxnie1wV3YP7KXxkeW/wMqYKI+SXDo2r4s5Eqs9cOaJb1kBYKPl0maFEQ5sn5YWW++c/I9Xgr7Uzgl+zWaIeHFhhO6Pn5ifW9VeZOn0bDb0UMrA8HWH/U/r31cXihlKITjerDWXiXrfOtOi4RnKZiN7lAzY6SuC/5Rx7vMGedIUnP2V94Lsz2iVzhvdAeAPKec+vQhHr4mc7kGhNm+GEn/GHP+w/ycK9zc7zTVfOnh+lHJqbN3W77t3hZNCpcrogksGWg8EnFikkBRNgdHCOcLKNa0xzrvI5IaveBoOOeNQ9ymwZr50dTjYcl7I+XO22ADat1QSlBdK4Z7ykVsiqFvdWKH152wv3RPHQYb+E4jyEHFSqvm7nH8+edOf42B+kHCiyJaUawuHepgSmhud/k8YVi+fSXXzGFB08ovXVH8WOOOidyI8ywBrZ75TUIdTUGTc2zqhsATtDO/fluAGBYuSbA/HD6q6i98mAts4MOp8nvZvqxWQ0rUTh6XQ0YO1R4mBeSmJfuxb15Un69E8U29wX2rZUBJE8MERalXc7tTSa91hwQKt7fyRTPiQ2vP+lXXjIFe5uaefKQ98OAElSYnA7s3uAC45UDy8GzPFbFkyoy2A3Ut0l0YsXz0p5S8YSxfPcvGRi3LZdL1KrK2xV//f1wgBG5anJwzvyYkxaIMh9llBkOI7baKg+7vYpw0EP2yl1/+VgKQg1+4KX00m844nzWUCYx67CXtSP1YIDN5Zl9SbIMc6ttMVLCre0RTsAJ45bkVtManRQRgXcgW22uexWF5yMsPR5mT00CJhFNr6tbHWvplIcN5YRS0Fg/421ORsfVijAqv8Aial5RcLKZGPoKR2Pb9am8JcgR8OdtEI0GppHdYEdJ2zGMmyEB7LqfNbY08AQ9bQUu6OYAIzPHbhB6+WBf/kNejt2Afd3d9R2JfpikFKgXGHGsuHpPfTwTlRSm3Ojsx3z3pgcWmnXuMJNPU5FeHiW99BPonXiYq8USgVDZmLfNnPPYZxsP9lYNq8Bu7LAXI0ZRw96cYN8Z14XocOv+cr7AuYrKK/4Id0ntfS1FgNVDbhFWkTkRECi2ll+3CJp13HLOhXqT59IrWnGj1qeXwV7pPudjGOonB7K5Qs7zGrWkWrY5mdCBUFDJb3I9f3g53yLYyk0v7Q9cfVRFhr4XVt5ftI+/O6pHSgDEZwSBZ+NYTME+0uZ4D6kaZp6O0BMaJ8P5pJfIL50VZoJyqc2MU/ImshQqiEbM1+4Ft3an9uG5sM5hvsBsEVjI/2iusbFNLxlCwJuOVsGqAdhm46L4WydklH7gNEpcrHohBqt9dB074TS5zS5ZngD+8QW6TX2LBXwrVe1uiGoACx4HUh1gxMx5SQ+JbdEAS+tg9T04H8GaIkVcaOR6VxOfBvdL8MNJHU1ysA7QbLKAHWkqZtjVdYnSTUnvuvOjDdo1ae/nLrtf9l6ceySWSYg6+B0S1pQOntgbhrI8Wvo9CPvyvtU48ep0fsVIk4rR5TXU3S0sCoAtY+rPq86kFLQ7B6Ne6RgvzdKBjbkgi7rHll5YIhdGt61fETzEyTF6eF6RB/5W3x0rNIwAGtCAEPmMseAsZpfqBP4HIuztHs7kwuQAmtPQJ+fOsuSkfnx0Y4wMrcZBLypyJl4HieTh5yiBSyFKgOEi+SZ9wDhKIUHqHdYVuhg+Vp09bzngvbTZMopa7M1dQtNxYw5eHgyd+ux3M5Bdr4qCs7umpToW9UmuYP/AvmbUfMq75lZ4WnhghAJtjR6FbciC4XTYglfnycdKQpLXzR4UdM2kum3G9jn30ux06VKh4dpk2hrCRWnK8z8dCCh+xu8OhrdDNLnNyR3H9Np0/FFBz6Mki/SpPasPNimKud0JRTgzV70/0vdJ7RlVaymrQ6XLKfFOb3Oo/SMciiLFTktRprwCJrgHjVd448MRAFkn8Ak5OPpIpCWdRvuKjs1hrAFSvbVh/WSWY7UG3k0gqBYGDihv8+wb3awm4+OaQt5htAluMgJze+BUDqFjx/qCqne3IcUF+q26qF0uTNqP+gWFPfopDb90HpDqxKH+mTV1U2HW1MHlzdgBUsECuv7ZilR0JQVJY6+axHSVExQlfTx8mhHaIz6jMFooL9Bc6+ID7hnC/7za96j6pggnEDoRzHYQs/nhpdO6LE9oSjdtpOxzn0DWe2KpR/LV4zayJiy0p1X1jg2ibk0EIpPGShKn71vRyfQkczvy9pY7Qw57oDCrgyoXmi3mu6jeS+GGt+OdZAznZpVFw7TWBWgCuveCwxwAiyhPUJqmcbzT6c2jzPbY3FJAnM2zdb0LBNneTxQKT5rv0sinewxYmEVGYPe59iHDig3Uos08Tljp7EdIWqYSbg32wK4zdLlm/3kbrfxk8rSlaKpo5ydKAcBSjXkGVER3xyC1VK0Z8wg77VkK7a7ns+Q7CVKH5x4/c8zdqlcK3WtiYswdsj5mWE8FpCvvNZMPxOXj4p3Pvn9y/PyVvQqPAvDww+U1lt/BPOG8OYxPYbqXLtfCErYGCi/KPUm/R4bgM/3hQssgiYiuQT54BPYSzJgfO176FRmJjkPoAMUgMn+c8HLMIsKKn3WknlU4AdeutoFMlqZpS5hVazz5wyuxAsY1t0SyvbeTHNezCUkdgtTbt+PWcAJHK1EqiR7Z5e7W8aqboyshS6oHq1HV4tgM+Qk6byD/x3Ga0j5rdgk2DNdExY8NihKnh3EkoayGMdV+tH700MBPK4wMd2ixzOPjsUQLYyZ3QWhomaOt4MF6KyonA9Y2R+hw2XtW56uBtuqVqIjrIBGp6Xm1E02wIsVrIxtWqStjJ5GIlpNK6VS8M3uxpLZxl/SyJD4fC8VZ9Ws0cw57/Hu3KL9ezqJ1sYOQfM1W/KfcqtXx7EA6pow9snKbbbGGlj73BRfU+mDyzKc2VlUkkCIXLGAgFXAdsJ8sJTWdj1Xe121SaTIZqPPxWsf9yr1wj3Z51jI+oYjeZkpTRDoWm0VvDmc66Vc4/4zRhNyewOZDeQl9uMt/3uaDz7CTdEsb29urp3jch4wr3Ngh8hXrzAVWi6bS2OUxxSSxOVYO1XhGr96wi1YO4Qk1chLYS2hRJ9Na9ZZZPJjqydT+yeVP9qkkmLM43ru7J3gjCfvL0bW7oibsFaN66PXsDQBlAMUCuVSirVopchOZHIVDJihs2+dL06Lm2OFIoli+5+KIiZDsvdHEFY7S0ryyW9gRD5ef8CgJwrsSXn/6Wo2XrxWGbFbHgj47leJSLHrEcgnFO1XPrUaU1D6u1d9VXr1lXKewpz0zTG5+cz72tIfTRA3UjQvx19+VDkdE43GPe0InHW+rdPHpWDcHqr5l0dAiwfHuDWlbUsVy7XvnLBl+B3ulnJHcsSOfsAnlBHNZzym6SUYEeTiYZC4nAT22WVpZC9zB7kSy85bQO91UhTlPJLXJQ7t2SfpiOP6u5KP4BBMdYDs1z4PpS4AL6mut9lhqNZNQLI9oOqCotkV7L/3ZUq++fihv1EUXlHut5DOrJmnaact6aFCV8k1oF4YKA6Un7AJ4leoUnPIapm3AeUHNpEQ/q0//TrFJDsQxkMmsRp4Eqaw3Xz6k37X8sYfGToO+Ioi1qyKDNTXczOcnH/YafBlA6uXqun7rQb03Tvfh+Gm1hiowr7yDmNHzjz231lf3X7r/W+Wu85Qk0T656aI+VqjZgLw3q23mMNBbE0ddpH3u3LsAG8vxDiy9i+8BsNoKEIPToS9MNLB1gf0MTLl4xADdu0gvh0C34KV30mD/8sHnTou86JEX86cLyje6QU0vzIm5J1+T8gxlFdSDqhfGvpzYBUBTLmkoW2HS6TNp3xpNUTSwvkWelcmkVTo4JX5tSHonXlN36xP7ozr+DDmMjsFCfwTb/Bio5/b7snp4Zwb3IIaB/viGuPbPSyN5Ur2SrDw5AU3JeMiycuzTBGFHNL1t/UTQGGPvf8REii3bdYTsxcB9f3A5naBKAgQDBK9TqN5tahx0Lxg/zHb51Rt+eE0XC8LLVZ3fZ8i/KhFqf5DL7KMPFs/syn0szSWcgDQYFcgL0OhuizN97YUUB0AkZYX62CbAEMtCEuxoeFUbRqXke0iyQIZJxd5dIymAGmi/Oxr9EmWPExVe4iYylfOMnsCXQiObt0YHywIljfKSvNDkkTSf8I/QyhSNa+Q5QxB5J9w3oDWTw+6ERCgVSxwGkktLAg+opWh6sQUPen5m5jj64ViY00lylpOJuvf2SElS7jRg14noXqChNKK+YBjiI70j6g3oLj7YcFoqD2dFzMutRFPhwpq5XuB8HugLwIp2DOMW6cO7pxqV2Pnd1MprOg041Vnplgee3a8Fm4rLa0rItfZV0meRGTbglp6Vp5ecvp0vITJvvUPPKadutVRQKqv348CnLBuoq9veakKsXNI6fZLRAKGQxbkMNNORwFy6rdGYbzcnPUyWDv3Fum6kraszLqy3X/LnhWzKHDi5woHdf6T5JMMZgBKW2ya0Z9lh5qm9J+Zu5lLbwFl8JRlWUaWHg3IGw7iNr6GUTrWlwdm67MhoM8QCnMMj5zOTnnPEytpmkGxPjU/IdC4lOdxadKUDUNaxv/XvdUz8URmP02iIGkyVnwlg5TzyI+xDDOswYIEzAf0rTcK/MtFy7b6SwEqnMMuNYVuZjS8LW4Xk6vPHvXQGKCrUYLZy57z4O7jR0fMepzB3xq8X73xgRBOqbHcflLO6wLlMg4xIIa4/hExTSMa1TvARPGDdwLv22WgFzy7detN6lcaVbcscqSEmoNu4Jk8nDU5mox1Ze5rdyoWGZ8savvSdj7upc7f1bGvynamw42we3geDg9UvzaOBwQ3ok+imM9b7vFewGv4w7M9mbe2V4yYp4CRYLvtAHTKsqzPaeef+zQucOcCGFoAGg3ar+BrluuVgjkjWBindSw6f6qeHuJBMdSSS1KwUhCRhv/kw5o3RexiolDKuWs/pMr0hn2FqVSCSpstzG3zUT7ArypCr7duoWOjfGIfs0D1eCSRv9wRgjzUMi4C5x+TNROnBItiwvd/bwTOtY1w3j1yyKOPa0t3Vg03927mzk8Ww235uGr8jmTGsDeg+RJktPAcT83LYEWU+WTvqgnSiBp4EJUPVi7r79Vru3TwoO0utXjsILSCg1aD8Tnl1hm5ugDNdoCXD68WbagTdJgvsozxQSktXP6u4k/N46yGUTBb6IQrjBA709uk9ES2/9DCQn9IuY6fD9VQqTa63t91IPEnsQJyWwVMbL1u8SAKiClBxcxp9Dc4zkGBO5B2Tjo/59DaZXiUobC+2r55G0mu6rIAdVtj/XdVKriq9rigahMqMcwIwdgtz+ZOWTnaXTz3ou/wEa8iLHZLgHyVKc4f9Gk7ecnJ+qUyibJjydWkeo7FDWzW9cldN0Y/PG7G1OJNwjArQaSSBhDCRZGzb9lFcAjvVoYRad7pfwEjN4dVPmJvr+3GX3ySO71yhXSmKCoL+UhBxUNDQ37Wcc7n+TE4KwHIEp7K4Wcdytw+cgM/aYTrrLE6qSP5LNJKb4bwlY8Ms+bJVBBm+h9poc0iHuXVzWMPy6KQnqd+eF11XKjYFgpGpklHYJ1akOPsHAKrNZesKt/Wz4Qk7gFpeesSzlCIAiPld0l2xXezyrRDOy4NWUs9K8ciLPp0GBedUvyKBq1+V6K91jkAmYGU2Bb/UOSKyDVG1Ac9lkzmwhuZ8EaKy2dA4CVEcMTDZmCXwmQ8qFw7pkR2PNyuNaS9oBUMNwf575vjA9ZOp/t95RtwrWyJAKoNRD9t1eB3mn0N3ClSbGen8nHIPYxNWbahHQwC8LAuuXcMMK/ffotL/bS2mdiRgXqRURBPF+emk3NxXjyg/5ruHmIwU6vkesU2ClRyCSR4OW8mE3MOa3549zG+aVoOUeHVlQ+QAHRyaHPKQ7BdR8UmNs829ujBExv6qv/NJ3HawC7l+qJWjXL5/S4OjuP4EJzSs19sT2betslpuBCfoMGZV10QQVI+WmRFgF/XYCMzGsMKtbdZU2Pf+q7UGbwK5YcY69O2WORCupVsypxnAcTIKTCC34zgl4FxRaKhQsAawCh7bAJYLAAFaAKPaoZM+lwANB0SFZURbUY9MBj9qMdISoD9GS5I7hTG01w6kHJX55ewBOQ1N+YWH4IZd3TQlRQc3vedR/bEvh+MJnsjt0Yxm04P9ONmIkgjGeTobanfn5sjEABN6+TJI/Oh9FmtQuR7yxV2/IPrHrECOh+cl2vwi/yIXU+4Ge3cgpF448WD1OXdzs171EzhPvZybWi3CiO/VUxlKweblDzAPpl3og+d4Xnwjf0ahoqtrTTRxWWFL1+erU5d0FZ9JkKSbH8EaauipJ64wXVcqcwTg4Nt6xYJWHpHQAnZkMTsKnEsiv2IF7AnPEZgHcUlxij0rQERVmItbK3ZOUidF26eTkOfyjLtUbNahhtcsJSaw3jWyoMnbEzRNuT1K4OkYLbANlQTbYPP2297zxxDu/eXRQ1zV6LDCaICU/IjOpPJZPLE9bd8bzMTCP3A+kDY3fjbEqA4K+ReJAKzWuGOQZJWFQeB4BJgk0GxKkAlOa2D6vVFV7nWaX/GSu2wZ+Ll8EHKB3U94OcdStLDOcY53wDZ4XI6KBqe5o2MdVitfhTKL3IBcj/0kJsYEWGWXKF13dn7O/prbnAMxdlciHsZDfSjJDS3lLnP6meZcshusKIwk+iPePt7NV7VvPrK/dCrM6Fm7Zq1JnWBH2JMB+z+KeYbl6Q8QxQ8wKR0hc6I/HogUhta30Bo4dim0i4cUaLRh1iXru8EDNp+73z0hYk2rK5ZLP0Or661xzv52NJwpLbRHOxNjDczykePdPVo+D1xOwL6OqLwyCd/AyfKUUa1XIlOMV/PXJZZLPnNgD7SLietVeeom6yeme966ctoiITxBylI3yK/zAnevnoSseNCTzLfIK0ddWreKroJKSqk9Ua0wAqLZc/zB2UknVCjDjpbrO5xAgj640xjfVVw7u/HheUIrIlIVoyDh+PfKDJLnq67wmO0sOTVrQyAjLO3C122S6WJGE/9WX1hd0h/PK52USQxQqm1SisJRY2C6u6J5P2EryQZI0mF+2KEF0vvdTKSW73lrNQWPfjAPXaLgjPWcZJZlqTXaquiiyMQvPW206JMJ6vF6mJKJP9Po5MEs3i7CaG88sIwp8AxxBJYpeBA5Bgbs2UGAKMVyz+9WJaBMrc9+RjipfAf7zVhiE8Ho6lvXIcUQ+f0nFoL8yn90+rv4Mf/x1NuwJ6maKFor/zIfUBMqWy0HioiOxsmTHJhbr4jRqEhOTL8qtCRrcc8yjVCGjCkwmuNTMM5zjVNzVLJGcR+353JRFWwA1hydoNI2OUW9iw87SqGLWkG3UXYEwEnlSlOq8WSS0DLhrdZL74cG9P4vJ9KbJd/3VH6lhycp3ejTcFPt8w31Dg4Recf8uKyn6n5Orfp+a05Wf7Lux8sJlhigJ2CXAxIIDWR39sdkXVdKA5YouMM9B/JzKJdkL67Q2TnApBIeuy3wwL1zKK/xuFVsvPKmB7dVOaEFqpCIjOmcACtX5Ar3cMNUrzhD77h0Rj3l4hHWJD/orX12BVBCjTrm0oZUke07sX16SIU/p1OUH1evLJZtnk+U45OP4xb03FMpdphmOCFEmjuF6h4UnFz6uGbxg8/m8BqQrOZvI6QNp15cIvYs9MVyipizQM0C2gIURC5jHDLIwv/A4w8b2JpdgJhGLELv0o4V4YVXr0PLY3sGmvLNJsPoETZQ5uFcmG3QTsl6uHvLfLeZE4a4mULVb0bwvmy1XD2XRXNQs5wzFofSD/pRF+vucmOEmd5VCwcgkdrvETWhgWa2tR4jEE30RVp+zcCCmr2bamNKaJM2V3NV6DqiofGum/S4Wmk+3ql0pbEehtXMJID2N1Hh5BbVgBsDo76LDsQFD8eM7QTwf+/XuogJc2GGNWOssDvO28fAyFgyU2wuhUF/+I2ZCw3nKQwRhhkd7WhnX4ReLxRhPQ1xnAR4ZKgEXY1CkqaKSnSEj+16Uaea9arU9e2WKJHBstusB34VCKKaD3cPgFMma7t1yC+R5ZZytjK1eWiyi63yY3i2A52KwfbBDIzZXnsG0coiJCofeo3Y4G65l8TbKuGgZ6wM9dqyVlpuW8hC8kjR5OPR5SNMXmHIKRVFpi6MAstFZ8UlFMdjfxzHpfPK2iCeyC5hK1Ff2Zui3pUhvz4niwb2VEwPcl+rh9swx1hkrN40nG6cESn7Whi7a3exA059JMcFYA0zHjKtnQOxii4USRAEnVdTHbDjs61q+UaIbRRe7xll+T7YNlZzrpXr+1WLil4rGmmAK5s3EYFTwSSWtY4EWpqDhZn2BQtpdWLGMmt4OH5ausnz68R67sID7Y02YnPn7EN4gCNOpdX2lIF63PjbCGWf8Hbw79Zi5nKnHXQ18SecOR3enTZ2EDhiLKTm/pTeUQ03x83urOzwjDGuYbig2wipcncgLplvUICqT9TWvYxlz7tAcMLqnp9WBQeo4agQFX+Ke1iyVJZbJRnQxEIDZ8I+Fshhzk8lrsf3JkRgo7V82rMsLVtuCgvGqEfmAZnuDAS5slhPeb15nlI4R9hvDieGHDUa3KmGhXcjS6PdqIwJkJ/DrbvvEIbR/VF9xlPCdtdHJLr5kwVPLijGz0gO5BxWexsXQuITPlA+px56QgmH4VF55YkNdyOhB8F1oeeBxOYcBXRb2whLDtl51ZELVPOqU85rvZ7bvvISekiGu1gg28Tk05Qm1fOIpSimBENDqCVD0Ie2xa4nNLjUMZ4wjwDvBASy7pCXJYJMqHddag8n2RD8lwM1ahbgczJJTFki0unUdvWNYtg3P5MFtUy72FUBDA19LrsaJjXHM27ZxVS8nWXen3f8Gutaqe2O3Gw2ChMeCcLs2qfM/0ZaAMSG2M2+bCjM4K7yX38TZjsDlpTjFWZ++vD90Lk2gC5fT2burJAsZXL9ecrH3S1mGJadn9YhKi4N/DipjWEUYJMKGKMkrFcSBOReqPj8Gv6Fvu59JQR7BCjAsc2DptEogqWEkmIlj3jDOCZ+ZjOWhaKAw13B5HcQlrE0Pokxk7JZWJRaMszibTIPD8/UrGk+4vzsBJmrcIiT1f7QVQiNJWKDYeiaTuOWGAVbJ1iWBalrqPjoWQ7t8qwm467c4oF250NfXTvwvjtHTnARxxubMcnddB0ucYahjZFe2ERo4EExZBnX7qrvDvHIlHNrrmVfqXB3RhFbayjLIgwYT/EtCTVvhN4io1I0cJDTkMGEW74RiCwxZKiWfTqfL05JvQ1zY0ikEmDIYBtkoOEU1fa1uAK+0GUYHb/eXpJeXWIbHBsd2aO9F7kn5z/ho+VbnSkDMFdOJ2DDtlaE9Is3pjWe9MtPPbF/pzNea4m3HUVYQu/+XtYYRLYXS4rPD/gv+sS9SORs7BRh4ne4inoVnBLIPS0svj+Dxayu3u5Ml8gFnIod+VctimIR2PmyVUsaTi6mGSmQ30oei/GUsZAn2P10830r83qfpqmungy94go4tTSu2WjotdNKjPBF2bDez6JRV5uy1TSUxcoyeRFbhLuEWVjpFi4k/OVqu85k3ajLDzaDmwF4CltmduteY7CCD412+jykMg01MLHfKVo5TfQWM3/9hMZzHA9MOUoDsMMs/WxuASI5KEuS1zMsUZA4VMV82WrHcBjZlQ6SJBkbY1gKBDwPDn3J+PBkKFdyLz5/SmvlanIBc10i3Yf5SFRQ/aAa6JRVyyJIe5rAJxobYNSFP3jho45vQ8smlTviqUsQLCaMWwUzRHbsfDuR14uXnJHtqE472APRSdwI23N75c5bB3wE2CCqQuxwE/5ZrxjCCr2rK7Cy4/kHffCcNatbxgJ+wm7ze12zX3mvmcNU9IBEZgegr1cxntI0MfEavFKibIOrlrKOo7ijmVpMu7qwoYRs347jPB4l9xAeRYNfVRO7Aqy4rjRNi0LVcTY5sijsAw4ZF8YqyFVO5BEdL4wGLFVuKMjuMcAYVzxwUbvzXQuHfhj6eccw/3lkN+kz5dNQZwFP68GDvc6uzU+wf40Uyb5N2bcKcqW8dEoRRJBlahP+7JqeS+gf3TqrmCRT5+dZXzBMydx9j6A9AgykG/tmuFqiCYN6z3vA6BYNSSY1OVFZSqGCV6S3Yt+zlC8/RH1qoTPdxNQWJuz8aaHH41LipjHi03QeoAXH9wBw0Iq9smRzTgDccxUFH4Y4kS+oGuYQ04Zsb7vuPHePmAQUtO3SRviQs5j1Cc1DDQuAnfJoqVLGNBujhAYzu9LawQZVmWGDc6WGJeYvAq9d/S1k0AkNOcLXwfT6t/hTqKC8MJ74mLz/exXlezGnpLEdj+9wKaJ6HN4fgwMJDhC6Os/Qt2d7JI77ADUHWy1eUt66M7AzF444UtnUhO2Ocg6Vy4sLEAdj9bS5UTNXmEr+dKHiH96s3yFVZtXKAWbVoReqj3BRQsDT9fR5vkaQOaKdr167qw6Qg+IrlmFEOhrSZZ73d4BnEGwEzJoQIXftQ2d+hGsGxHoThTq7zbaE96huACxes9baet5e8Lzqb5JV7/cEo4cA5l3ou2y343qJZ0C8yzpCn5K1+LWD3reaNYtc3KFcuw68yf0tpyb1XUQuRpy+nBUdRyG+4kVxJYjnqW3MkGJvaStJKHEpb4gR0t3jcbvrO1w0rBEgK3xUTjQwpu89y8q8zaylYuhsZM7KuRMdenE8XzSixMY55EawQNWqVDB192EYxoogFi4+9Gcf1iHy1DERvK1zGgffV7aaliE6DdMC3XBTHgklb6mzDS/IEC/EJ8LMep4b+u6msehb775JUaJzgg/0DKl8/6Z3wF7gu/QfUclrLfE7WxHQ49HQ3+kl4aI+OcJ8pEo2v/++cz6QIudlw9QlVN+uMPeHh/BgrAmgBFbj2HfXwpe2+cZox5pA8yY0TiIyFR4sL3sYraZKB/8nkgE4fcZTy3rTwKyWM1AzI26mrxhz2ebuWTkZV74i9bmWQBfTTKfwkP/z6qmTLLWFT5cZMTN9SAksgsdowJG9PNH0kzs82laD1xyIeYUbzGNpQq71Mo0rXYbFw7imoTEIZwFO2ZR0VMXjSizv1pAvK8M3V59QcQy+Z45r17sRsVhXADtN02Irjbu5eQxGfMVCjaguAA7NM5vG1ayVHV1bEI5c/SKFEVzTBs9ZX9NJemQRiq3NUYZ+CBLOcpD4/QGiO0b23IKNRkW8Tpph9Wtn6u6dtB/X4QBHVjITBP9IMSnnuFOZSxVDGRUssUCIJlsJHesZluWeWJYbNk7Zz+h89GHHmBMDhQs95xTUtvJZ17cpUQN8xOB6pS48Ua4Je4M+97KWustiR8rZQMs9RD1qLS1cZIVYri/AajIQOKrTH/xIOdIBViJ/t5De1uLlsRcL6njG/f07rWcKPbC75PVWx/F73EtnrqzkSzwU/PXgAjtjs+UomPDi2lAVxf4hfMl0oHORsYr3UGVU0Ptcnx2psm0Zm9v6Oa/D3QPC2ruEa/oWFIaUIYkljiCtp81UW3V/dQma3CXfHBXMAyIZ9+0nPzC3ebHtfu6a/j4IPZTz5BPCBNk7bdbmWQoFYL/3wbOp8iY24BbrZAJ1l0XWNUDBkWHCHEiN9msgR847CW3mB04ni+/7nhdbnnuHP0IcM9H3ipsvJBVYWtMQrHmteiqhO6B+WEbYIXV8gC1UYKFDX5lid37mmQ+51iv0zNSawMGi3JZ5GmqIgY4x50b91orVOBtYsayAux9tWO0IbFggKNGn8R51nhvpMQ+AWepaLIRrX6WH3IsstSKH2lMRlnjedKuaMVPpOlGOYWk+3FxsU43Wz921OOSC43VdRwYbswv4r7K6kb4DXNahQ6jDB9lxCn/yUbAQJ65Ry8lPYBqDo/8TegZr/uLeAEkByyRZ3n1HftbQIxMPodFyt7/eX7vaEMMLxIjNBbSzGGZe7TV7hlvDMvC/se0elHKQPLIwDf+SUcb1np5VlSIumkEjjzI2JpqdiUi70fhPeUY1ZG2+/sVzYP8hfMucFnqL0JaaMGeX9VLOAb5Zbi7yW6m824xR8DVo2dPprNh/nR/MYAS8Qw3uaX3POzf7Bm3Mfs/3yXrrp8k9AnR8DQ9Q+jAEHC3iS3ln3HmAVlTl4EHqp1Abt8Ku1qa0a1tSV5Q6z6JeNwxkGcexqp4hjuNFj4z5miN1r5UFaZDwdsAUvl/EjJIC74lxh51UiHCn5bsQ37Lf4JYdD1/dmfsTarw7wSyogLIpTnMZfThVtOgBRT8SH4tqG+DiLMZj/HQfoZZ7pd1It912HfFyhpoxQIb6snP3GlfA+0inHSXa/0T5sLWuZMITMp8VEjj7n/c088hWTIzhTs/0r+/zWQQxODmucXH66yR2jACQFOvFQyQ4HOGaHAJOhrGDhUX27VsCC87JNhGxey5BOIOyoVXTcC6Wwcc/csrAicmNsyhuxzZc0rKImMyk5XtkGEfDaq499czZKn3/7PPhthf2VCKEo7Yr/tpjVijsA0GvvHIYjew9h6jdDuy8fz3fGCi/VUQQLkTFaZ0+dK+NtOh50fSE0uP3z5MiGU/bHP7gIKDKeCvglNebWCwLT1/UrNZo/fc7Jc+nnQMkW9Bff+9BBQQELRPdhlPVea5EtAdnBRJAx0ZLV+nN/UdtAtaUdMvWZ5IcxCK4UDOB1VZdif3PK1+0fsfvErxFSflI/JOkeKO5CH4vmqCRwYngLzHYPKJ6jMY/y8rXell2ROBziMrqapmTCiYS765+KHc8oLuT7pX73W/vDNY1EA6RdZSjec6ADWDaBtYcysfl8SBu/3hfuD5rI+CSMig8jHu/3zwHKPOpN1mx++1voax/bN9LvGiZee5+2rlTU3XPbjVm5rfvyx8urqnf16IewInqXVnQdSi/+/5eXiUHvAtsviZ5z/ifpdnLZnlAfnMZv77AlihZgRjYem8J5JOdUjV+RxwVA/C9c4p82+2i92fo7Q6AD8w2nwGL/f2sYS9o3pZoMQhTr46Rywt7yC/s0TVit3vyM5ppb16aEXrMBDrC4hsQkTJhZb+nhDP45W3km7oxEWLMzpIR2UAG/Lz3IwO7jeoqvvwD7V0bIUl3Ldwzq17ZJGqkmwP3JJqBFIuIcLZgh9xsx9x20PPko80e+d3JAnkgqPz1YpZh6DkKJuBHIyzff++y7IGyQ13j8pTe/S4bdvzZliscyXXUhN1rigiI8Ja65Zn92r8Gyt+jW0QlQBQan8LQCrQ3KnrLlCT6fPcbfbxFQ5JZ03jEX/YMQ5vrxOdrem8/vInGGVC7Yyh3vzyddxST5Tchcc540S17oPQzge8cqZnwsQcYBz24hr39XDhBzHO6kwjAL1mPGHR/cJdg1IvKaX73XJzEjdjmBFNvijOZ0IMx0FFtKk2kW/27/S10Z0MVx7vOc++oXmnhU6PHoU8BYlVZQWhWmoy1GdXLPfxdBH/Hig5VFh7XHA6clwNkA7YsOyotejr7ev67feullmfLtWg6y8xvwRqiAxkb0qIU8Yd4Ag81niudZid7x21HaqcX7lpgzP3E5AO/g+/rcnW9ptYU79eCEnzF3y5gXoDxF+HQPqZU+K4w1ZXPEhFCBWz+jUSAa5HreiEjqjHysDndrTStODh0ICv4R7qLIN3R+eVpvae7UhdHeiSQSfTph6JehWuzB7xeSoeSxc+W9/Dk81OFQoKfA/iWnu6aADxw/Wl37U2YqO5gVxEVYekmBGDOpRvM30tOU1W4C8ADWBJ5Wwq2npzphbAGmt/FlmAS83u9pC/AIhYRN86j+1/nIjanI28d7hPaMalarSbfL/4eukDdkCaBHsTamI5yAFTe7GSIoIvHgQQs1xweHD4WnogeHupsIgY+3VnsSmdournmr+VnmvZXzNC1mbso2IGoQ+jergeKOdwdGt377gBesF2pcyDO7/DibsOLQeyeuvfvubO72zE5MVS8Jq3QcUa0L2RH5bNMI/HYSlSJnZP2utvo6mEdGDGt+nZQh5lBzaQ1RIj6BN52jzBmBsGkSmuIAuMMOq3Ls7LG9D7k1n9GOn9735hPRdrFoSg+kvOwPidTWvA+cf0e6J/18uFUsScH+d7w3IP84eyyA9l0d9au5MTVLs5HKkA5fJgzU3kEThT9FmH8WE92eYuvZ2bqtP5QFlQHqPO9hF/vB6D47J71sPA9dZuEDJgPeibAC5mHxQMGkPvhyse53/+Foz/hM3C1C9QIF0qDXNr8pUEuBEQvtNoFj/cIsNzpGeHtwg/axjtBLTkehwP5Gzzy13cdeN2YbrU9+f66wp5uf/lNKe0KIjbIAbmKtRL3m2sGIdEaZVao2ORkcP0YulTy1pxMvGL0ZR6ElWxidsIoJZjwE3qZ1wyYZtkbhwFCHaCDkarW9/Gwf0QUsw4lw9UkzUvzXufQm91FDYnzPnprCIcZZvU/wzXMqsMaT0/eShjilQUHbfiJEeTZhOYpYR3CGaeX4YrFNZ2XMG9Om4cInZG8rhWSVvSIJUgp84e4jnFyodKbHhRaOukyYo76maHDI7rSde2vmLlVQFXIcHpOGt+JTU1Q4RHBkS33O+iqQ3p/nJ54xZLl61nBAwB+VYbuCqzF52/f/7V+9D6MR07arXt/XqFEctuE35bcv//NBUjC5iHGTS1egsiU27mkavfaj/sqg4kbYpPOlgpHaHSPQfMix2UO5UTjKBMUe/CH+k4TTyYyX7pb8KAWPKM3LHteP3/OzDWPO6AR92vmXy/vz6uE14BeaQ/TP1CYpuyQ3A5ZQGFJq5/j+4xPsubdQkghSRTXTHq2LsFOdhLjcpWqeizSGB5LdJcua24eN4IEq0E768iKce7gLZJF4ufI2w0EzTewnCC6uMPDMo8UzuptkZKJRo75htl2Rm5CK5IOnuHpt36Hn9i/3pOij1kW9ewOoTW5lGky09kKGPaD3hZmEdKtf27MIvqMxn+TR/BP33VUAVbPFUV5fCefYHh5xy8xZzr5Z7xUSndIKzrq7rB/4TnuIs/A4jXKliiX93/rdga4hh23PXn4zOYToY+iIvZYdfr0Ppt+ggj66asB/2HPNcjf0syT1Wd7UulnQG5xhJ/10v5s76AJVYTQc4EJj8Li3v1t7Lf9y6nH+p63/rYnOx16AcKsvlz/bf+a/RH21z/oj2b3F/2IQPACmrA9Tik+4SNduECf3oLO8eW9X+6f+U0+FMBc7AJV5ph31+AouH8opbQ198mzGoUN9yRv+Tj/nTfnHVaH02qaZq7sd1GnfjOGbf62jw6//y03X1841l/Y7F9pon/5uIZ9fxW/eeqbp7556v8WT3GN+civMG+X2913tnf8X85jVQRtgJHuaSyewggz/2dWzQcrjkWQWafNdXQnjXrSylv+uHU/lN3pg62CbXmJ0tAe9stnfEJwBX+bHWWLk+Kw4U9q1r7xX9mTf71DbNzEw1xxQkMkzFn1xvmdTb2DkbwMoJv9RfrEauL3RB0Ze1w92gzVX/+Lu7/bwaa3E5N9djrpjzGp64wGw7li/cEnkfdbxEexzsCO/b0lt5c94fnvnt03sZWltlavDyykTXrsRkZYb88Pf89hzsZz2sVweuBvr5X351GUZZiQGpG8OYb25/T5JhvdI2CxEGOn1IkBD3+Q0R30GftHuTotn/ncRBfi6Wq54tZvserbWoUl+Y68W9YY9s2SpjDpqvc+8149Qa4Jz2Up/jZnCXoYODeQF4+ZO+hB+v3u/vSq9Q7G4zEe7cuWrmmdVD/wqgXvanYG7smf8XW4m7MaW1jy6vluDWy18oq5TlR8jtPB2rJvhHa+1O1j6N7xuAx14+q3AX/5/W8Vu7/ttsAwOOLwM2n/Y932XqCOBj3iQ5eGu/UWfKCnEtITBujp/Dk92YXQlQ+CjjHqaDzpy6cn+9P/Hb/NQTqeiY8ycuhvMC7vxqenl31GJ3MemaZhzFRXTi1CPU0O32H/cr6ZfZYOGbSeYRYOb3Y00wvnGH8vUXXoGxTxdQdU4Wfv4G4+fO+USA5MWJgobv7kaV/rzC4QxCxHuiDsD29972v41vHxtG4xiN+/9ajzfeJeytNvvcjvPbaHhfMrxpHvuLka7yNCWrlbgILxWKPcf/qetVSVOeJb9XrDxsnSVOKCGDDOeViplJt/79v8sbbsUudv4So3o+fUYzOWt+PP75mNKuzpcxdg193xd/Qq2v2J025mYABt5wyF+Nh8I3W+8g+ruNEtqf6Po4MfpDze9Vm4VLf7QCvBmb0X+xkQvBaqV/Md3rlBvNMeisr+ZMezSN/146HBx6N9ntWSviC2cD3/+XPFNwuLlk617rab9/x7ClW3CM6pDRziXynuv1vzHAujySyGg0//RSP91+usgP0ADp5aZZ/itD9Yi/YEKxStaOC+bC9+rIVmD7sktucjeZO/ai9+SE9EgzXhNaJxX7YXP9bHFmJYr61uifNVe/FTesQwbyQeGIH7WhqBPbK3CJZ3fDyUL6URHmbs4PDaeBhx3KdS9g/WqqDCDOk7YEt+/qK9+MtHzMJrs0/ki2kErNWsgPLtcdU+2HNfsi+KPcGO3qndc19KI3CtNnthT56BECS/lEY2DIJo4NoYPu+5r5WvgFuODUSuQAjeki+lEYh2rQhc24qHmeO+Vr5CX0YLfRmALRvla+UrlyEaBrvN45r9xTQC1q2wh71c7rfxyn8178QCk5nGgxXmr5avQAhm2b68B0AI/jvC+m/XuN3vyzpMwYt8rXz9xib/sP7GJt/Y5Bub/NH6G5t8Y5NvbPJH629s8uv6G5v8ff2NTb6xyTc2+aP1Nzb5xibf2OSP1t/Y5Nf1Nzb5+/obm3xjk29s8kfrb2zyjU2+sckfrf9/h00U9kA/bKC5Oe54cnjvG6v86fobq3xjlX9ff2OVv6+/sco3VvnGKn+0/l+FVb6xyZ+uv7HJNzb59/U3Nvn7+hubfGOTb2zyR+tvbPKNTb6xyZ+sv7HJ39bf2OTv629s8o1NvrHJH62/sck3NvnGJn+y/sYmf1t/Y5N3e7ENCIIjHUzB5rgLL8AWh35iiEtzeOK59no798n1Guzxz9nc18oasC4YYR5k36Vb8av1BnjsPXxs3eW+GrnAx+ZyxV/ph/hpZ5I/WAvctOdXw52/WtJwGQM0qaLg5EPsv1xrgIeeDZHhf97sq66tC1qmiAh5tcYvlQRwbe0B9Sk/HvpLr63NmoBcY2uE88G/9BxF8NC5wiw699WIBTz0XtjFqTXezK/VoDy3B0hL0VGL+2rrRxBnoEGjNK1u5mf9wP9IQsrgoTnEmnfcl1zv3Xrej1xkuXc4WY74Yv253zEZoL70x2N/6bUnqA3uCOyAnM1ffJY6eOxduu7mr5RNr/XEZXsHha2UKZv4Uj2z33EZwFnRemK+WMuAtcUVex7OBOQDm/5SPQMt2RA89tZJ/4dq/7qri1xB8DNrnl36k96Af7b+RiTfiOTf1t+I5P8FRAIHtgr6HT4fnx+cnXT6xij/5fobo3xjlG+M8kfrb4zy9/U3Rvl1/Y1Rfln/L8Mo34jkv1x/I5JvRPKNSP5o/Y1I/r7+RiS/rr8RyS/rb0Tycf2NSL4Ryb+tvxHJ39bfiOTva4BIwHaI1Lk9TjBfxHn0HPzvePJMRyWFy37/H1z8D87/B8P+gyFT0g3JAj/BMPT10SPoknp4fcQGu4CvjGZVcJ5O0Ei4B+f/s30P3/0HF6pFTpoqGbon+N2Pq9Cvyzxf/8QI8vXvuYiH/O0rFIa8PsyTIsvf7kUxbx8G/euD7Oe14Vzw1x3hnPBFSO73n//smmZ49ze5Cx653sQJ/Mb/Bw==5VpZc9s4DP41frRHhyXbjz6StrM9MnW7bZ46jERL2siiS9FXf/2CEmmJRxIna6fpJkmnIkSBIPABBEF2/Oly94aiVfqBxDjveE686/izjue5jjuE/zhlX1PCoVsTEprFolNDmGe/sPxSUNdZjEulIyMkZ9lKJUakKHDEFBqilGzVbguSq6OuUIINwjxCuUn9lsUsFdQw6Dcv3uIsSeXQbjiq3yyR7C2mUqYoJtsWyb/o+FNKCKuflrspzrn2pGLWn3+OotsP74tl5F0tltuvya+gWzO7fMwnhzlQXLDTsvZq1huUr4XCxt/mQJjmZB2LabO9VOaKZAWrDBJM4A/GmzqdAN5MeavnBRpBbw9Ugmu2OA+VoLcHKsHV2bva+K4uYItgtBT2jja+0xIQ/vwJWbM8K/D0AF0HiAlFcQZ2mpKcUKAVpADtTVK2zKHlwuM2zRier1DEtboFtwPaghRMOI/rybZQPOcK2Fvx5+Uu4X7aQ9uy30soWa+qId+B+1jf/oDHHxE35g+UM86IUXKLpXAdz4ffSw6YySLLc03oDaYsA18a51nC+TPCh0OileNFxRFmkhXJ+6o187m0MSpTHAvR+VRs4x0JagF+LgretXxagPwNJkvM6B66iLfeUPjrXmtvW/7vSqdOW74fSCISQSc5MG/8Ch6Eaz3CzXzDzQzfAmsV8UFpNoy0IGSx1bnU2dfU2XdMdfqhRZv+2bTZN7R5seGz9sIK4jcUnhL+dEVJhMsS1HQybQOCY4SHi8jmS2E0xDcLzX2Pjd6PtkzoaJbxbUC3WMZ1zmWZwLDMjPDl5F3BMIQmlkGY0k0Bs2WqvlW9CnxbIK8FIj1aLbM45oNYzasCQA23z+RJviUwWT3pbI4UGub6Qqtgzhc8z/lAiowRWrUdVEA+4IxzrmROeAUOFYx+u0MNHl44cBGPeaIMrShHZZlFqrLxLmPfW8/X3EaQ1tSt2U6YrGrsZbqhG6I/47/tpb1iV8Acv0sGvNFizpsN96q1b7euMM1ASZgexkSUyZk8aVEryZpG+L5+Qp0wUoLvZSg8A8fK7sJETgsZgS2jEDSKcwh+G3VPYgOLGOGKJ9oNMCFbGSjIDPuByqOeuvisvQnQOI0CFeLDoYbcWjUGowq8h3k/Hc/D/47nu7HZwLFB4LUCQDscT4yy/pEge1kYG41UiAV9DRlHQ0xjFOrB8cwQG1kgVieGqSsTQ0nhy4qCvvDnmsgX3bLakI2hA6Bj17yUXCy5jdOFf3OSr0VzTCO+FEZsTbEcFGZVj6vKAmRTwJVVOr7r7EoMcPkGoOLAFPAt9OjmeFPVdMpGKqRI5ZAFh2vKH21TWtVZdK8l6Oq0cr5HJTfDksTZIuOrS61zDuJLB+x5yTNbB2KgPwaaP+ZFKZssJ8ssxZ4WCEHT+sJ3v7Oud1eCQyDTWORV4Eoh9cSFNc1sBa7654x7N1cN9m6lfy2h6Y9CM6aMzpXPSInOswAclZx4D2YnImGSz9et5+qToZIvhTKV0hOaUycv3pHrSv9FrSuHHY3cpQZPTF0CnVH4vOuKK93v90F38KdC1/9fQDdwngrdgZZbuc8MXbPM//HtvPf1L7NkZimipYguUbQ338wx3WQRNl9MU1QkgGbjBa7rdJrXyOJ2Qdhd5ZqWD92g6Dap1tVPdf1d0GNEbz/xlZpV3tBzgqOqRudaew/lz70GphZ2h89aS3DNKvRZUfA3aDpGjOPAecWG71uSrmc2vFkwN0zxUo8fQCsv7vzBtZS5MwrbKVJJXm2nhFeUhqJfTbnbsNw5693k5h9+BAvzyGHeKcljTLULBv2PaPmwtZzx1Tv5wZf9SnwwJwu2RdVmeb4vGV7KLjNcRjRbVTvlqucEglQX17VyzsncXJcyXnIOObrBuXRJEUCbIBwIoas9s9zv3uhBOs42B1JQf1VJLo7SDx+2+90Z8ZVOgTpBRQylo/egy4QtrAgfs0afuw7IW/5S6WxyWAzucZz22YL0nghgzvPcCaKRPHG3lrwPZw+APXQRZ0wMz5szxBDvNZg0xQyp9UNxwx8r7zGwQDe52mOB8hJ3BjP+50/0+xXyRoXtVoX1ZoX1PoVJtNEG1ssarmVsneZZiFaWlrEdTcjqXsXZIpKrH/DIhL59wGOLSPBlz2n/3LOXuCNCQVMEqcesNOb5T51bA+0z/rnGdd0MlyW/BvUq0quhfh9haNrwmdOrxx1qmBeC/pQTuseZ9GTnJNIJXkhVYODqAHziQYkZjk5W0YJmcy+x7t5c7/Qv/gU=7LzZluM6kiX6NfmYuTgPj5znmRRFveSSSIozKXGmvv4CcvcTY3ZWVeepvNXdfo5HiJAEAgazbXsbwPgLLnS7Ml4fpTVkefsXDMn2v+DiXzAMQygK/AVbjo8WFMXoj5ZirLLPtm8NQfXKPxuRz9alyvLphw/Ow9DO1ePHxnTo+zydf2i7juOw/fix+9D+eNfHtch/aQjSa/tra1xlc/nZSpHEtzfUvCrKr1ujFPvxTnf9+vTnVKbymg3bd0249BdcGIdh/njV7ULeQvN9Geav0wlriZb2mjNxU5p1p8Ptrx+dyf+Zr/wxhzHv5/9y15J3U8z0ORobcztPjGdcQDefU1uv7fJpsM+5zseXBcdh6bMcdoL8Bee3sprz4HFN4bsbcBrQVs5dC65Q8PJeta0wtMMIrvuhBx/ip3kcmvyr8S8YjiCcKIFR8p/3zsc5339arX8yU/QP8wPPzYcun8cDfO/LbZmPbj59FiM/XXb75gAE8Tnz8ru1p76c9vrpdMUfXX+zK3jxadr/hJnRP9HMwKISBf8D7dl1Kt99oL8Y/nM1/iybMz/ZnEF+sTlO/cbm5Nfi/Mttjv1zmz+Gqp/f9yV58D8YnoD8hQTvCPDqbxj5U8PP1/SPDeivV7CPHxt+vqZ/bEB/7h796f7ozwP8ruGXqx+6R366P/LdAMH/OD8sc1v1wGO+oBh6YjFeswp4xk9+9J0z/s5X70M/fyYDFPu6/jQ87BVg6QO+7vYCJp6/XbeJ+FsBYuDxvqUG0sFv3/07ePn3tB2W7O/Xdv4duoD/ZOglv4Mi6PYVyA1cWxWw/3mAt7t+XrX5/d0jmEnVF+b7SsSR78MK+ZzK7+73Z0UWiSA/wRn5S2ihGIP9Ds/YPym28P/j8YzEsX8KaAxO/g7Q2L+Rf5LZif/jzY7iLP3PEwlG/HcmEvI3Vqfe4FOCyVDF/LbHRwsEhx8WhHouw9cbf53eiMiBDwBD7d/e/OpFHALwntbPOcC9uQIYiCF/fbfcxytYhyWdlzH/uheYzMftfhwCaP51XI/fDgqi/V+/jAiHRQNjk7+O6+f7I8Md8vgSvvzNmKlrBx2tv03wr8c4pPk0/e278T3+tcMzwdggYR+y6l5Bv/2wMHRJGYWLjwAcRBAavIGS4A8S+/1Yfgol4Nrzj/Hy21D4HeX9SCGggfx2FcJkI/4V+0dxOYBourdvgVFWWZaDlMT/GM0/ZZ6Pnz+TR/+YeFCE+jUWCZb6NRbZP4tHU7+E4tv9/mUL9xMV+JkvdGBd4E1+u36/rtUX4UF/IkTUnwmgBEJ+5Z/PdcMR/NdlQ//2m9yF/VnrRv+yRnkGZPrn5TDO5VAM/bWVvrX+ZM9vnzGHdyBBo9b5PB+fVr0u8/DjogMzjscZfv9v5Ndl8tnd+0Lcf7g6Pq8+xgoH+F9YGDDJYRnT/H9hjE+lNF/HIp//F59Dkd+v9Ji3AGjXH0f3L18y5vehhnCuBv5UrnO+XY9/Kf2Q3z+/4/TU+wdmhOstb/lr2hTvG/13kRLiqwz2FVHorxUF9jcEnP6zKAn7742nH6LpW3D9g3h6X7n5WIG55+P3+PhxJ+bX1Ia8f96ucB1nDtYBv8uusE2uoMU++8++PpG212mq0o/Gz4/8iv/f3+DPDvavktI/jXaK+XdGO/prIfC7cP+/JMh/LGERJPOuwPws+pjfxDn1LsX8OSuD/mL//85YR/8Fsf4vCuI/PVSx/2CoYv/WxPw1zF9CVbzO19t1yn/xl69iW3oARZXlI/7Po/b24UDm7Y+GP+LR+agSfiHrJ6cl/yeFOvULR6bYX6UN/RuG/KcpGxT/d4T5/5TIJP6jlJn6t0bmf6A+99sth99uO/xu6+G32w+/bkH88LH3psBv7vBz4+/a6F8b0V8/9rWP8Gvj79p+t2ny87fR33wb/enb7y2L/2fOf6k5f7sD9I+4+c87QxDycYJgsO/eE6sRdFS9d3N6CFC/pgleQHGS+l2auL9//vLT/stXUcaEucMdpuqz+9swz0P3m6rNj7s8KRgVZCY/cNR/slt1nR4f5rhXOxzH77evxvwDyD42r3hw+bttrPfm1Xad0/Lv2J+Y4yj2R8lKE79JcL+jssyfBY7kvyPBfb+y7E++/H2++x+QA6n/aA5k/6058NcS7f/Lgf//Bm2WIEX5PwnaJIcjPPl/DWg3wL5TNf09AxLr73dgnnKY/tTdZ/LH3ed/P3r/Wyr4PzkxQdCYQP7rIDvfq/n83evvShzg6luFA178d20O/G+j9+dXXYix39EBmvkbS7Hffn5wLxanfuzwY5iffXxznV+6pb/KXv+oo49s9UtHbx/8Y3b/G27J/Dvc8r+6sfQPC+Hf+TiNcAIh/Ot9/JtfJ9+98+/08f8AlSHIPyUYSIb6G84if/wQP/gwiqDsH4XffxIQ/zI/Zv85a/pKX1X3PvLMv//mvpLcbzPefzHr/mZj/POmIkyAf8G5j0tMfvQFoCLViXf8DTGUYuDAjx1EpRQV4FUB/9A0gUtguwm+wF9q2M5JreSdfOK8HGxJW3V+GdJ2FXOwEnKPuWc6Vp2/YHxMWrEPmkRW5ge7MoTSly5alRtS6J+ks9Rh6W3s+6y7qyG79kzOsRHHReCbd4xDXwhtvdbstV+5UUTS88goQnsmnyRp3GVgcL5L1MtNvIpzN/OJllwuo/VYrSYBo+Abjdiau0dsHYcXocg6Fx5jjqK+t6/dhtlYVsn+SF/iDgfKl+5tx0LlAl7TLn12FON6Ye4dPCDGq/211DhP4gy8BsPjwkXyNA4aI99Ihavt+0ZUEldUuUto2r5Z2h426RaWkQUmz4vFTai0oGJ7a5AkrZElCwG/jQp+FSkiZPPE8oXLmG5GYk8iLhJO2+YIOCK/jrlc37HtVqn4WNn4KMj0KOjss4AnXwayGULw95KI4gLPTPNi6mfsSKzlot3LiyK6OoeFlBfWVCbkQeB1KWLuVXVoivy6G93oeg2nnbmNbwoz4HZN0PjAWviZsjRfawSvSCavkIahlhs60XZS10pZxxim4Zl2uKzkSzXagV/aRH22yaunnBJ1tLKzFbu8SqRiqs/znZU4rRL0nLCbgsstO7/rDt6DwSrgF/UnslMfWLGzlHv3FfUegl9fMc+BEt7XDOMn0lu4WZBMN+C0UZTstdDV84xjWzfxxkaGdH9K21iSkxPvxQ+KM7YqiiOMOXGNWRwlT2SpjaGeffYu9f3gLL7knEkxS4NLWg7lKkHbnnrKj35q72BEPVF1kofvJFn53EMTm+HS6AoqFSPTH16Z6IksXWTqMVls1QZ6UUqeRHS6aRD6ZmWv6Xx7kdNucgr9ylAnBcszkcjWFdt+lj1lFEZvZ8T6iq9DTU9JjtijmWydzmWTRRxz1fAVIWGKYkGIwmRXR52a9wdp0wWPqyap0AGHJKRRH7zLMhXAf4AfRQhpDOrqKqFlCqfNvopNkmnCKG9Sr4z2KAiSmApbBqa325EqSBzTk3ukgxiS50QXFCG1JNMHHltMwn6dufucSenJlnLUlrI5U6sX41CSdxl1YqNT1tye7WkbKyyJzvn2lATCKEJ9M7SKCD2VOZ68YGnyCWlXOO7NYjTTHkSUL6SiUUOlLjPuBm7NLvhNAmNKYQA2vCJLKqtupWA4N80LTuOYZEp+CCcjkLLXqcO1fVi1vVm0UjAD4/QqvJIHccBLqkcOOmfA1XMD0PH66tGeshHTGykzd+lTPxfIFbxxsmrR2J4MsC6fRb5dn4qgI9DsxPGJKPfPq31s1lPTtyKYb7ddErxJPAvmvmN3Fh5Gy+XxY12Qpjhzp2ILjuqiP6S5UgwhwBsNOQPcOTdwVlEUlZNW4qbmq7bCySUaa2WvhpTgZv0tY2WPC0ueIox7P9+vnGy5Sn0tDDVjzKVul0Z3JkuJB+cqeOCG5AXN9Ks80EWui1JhzzByBU4qUxXRGOfmZJNT8any9EIjti+oYLcXSW8lZPV0HTiO02uB1vl1sAZpXLUz7T0sTyvK9a6fD3ImwEpzV9RWhxMqjv1c2+sLnRRe2vBEwjdLHNfycX+Y/LZ1jfhikmsQGojgSQAYA2lzo4qQBzfJrJcvWLoPUPB5EAmXFI3E4UqTPNpGa06N9mjHHPWCBU0eCTCSHG3Hwxg0betTz4Gp9x7vE5+1bh/eiNdouLmDxOyJkS/kxvX05R67XY29kL0njMToFZrLvEfHdtCFIJWV+1vSwzWK83L1Jl4LOAktF77Myh0JfGKNUe6c2A/F5zbpwZuthJ0jaV+S10yrzLnRV53RrYfmi0Z3w7h2y3GIsLfQwdAnrhjFnPBriVGIVFcatcXK60xHsfdE8aIbewREIcpfvMAobYEr3ndvi4k/ld4AlrHEpHp9ea0oBJepduKZTnmM52K31+ii5YIRus1CjeGJJ0QYrNfDsiisxoxnjYXPHZgb4atUFRatkobQnhyQwnnhOPPZuMfZvHMe1+jF01cLXwtD4+nPx6C6VVQER1w43bnO41KQSeFqHAHaEM25IaqoFj7uGoB0Zp/ADFr/cXPc65p6D/AOIqpmnA/zwPbDZMcwXOsSiATeVQ/KquYbWsygy+3tCbGUyGsY0EmgnJP6JFWJHgsM8xDIgsSmjfVDBa0tHFcFzT5J2WXDOmkWAK7zPvWSxONcsNwdoZUtORDR3wUnjLMNVx9lQkyRig0hmB8ncBXPRgqHFadYSsfHOd57+ukkWKORa5IF6bwiwn7UmYGpnd7gkR+QVPmUZ5BAXqI41PxjYCswIeE5sMGANZmwC0n0Yo6laDdZrvjdPan0CebfXfXi9PleSLkoNh6QR1l/JPjN00ZhbpLV98Bt93a0ce5aQIIUGlLtHydZkm8F9pRXLphkn0F7ti4fLn6HUHd14s5/XKpFY4ILcKRa6+JBltGjgcFU8DYRcBHBLJzw6JSTjGgKpBGDj9jKNJcsnmokGAdPaI4Sqq/Z6JjxFMkJxVmxFvWWY/D37dI+RJmunm4T8QzWO0rDTmB0iXCmsj98s7Q4V9t5TnvsknaL5TXba9rxckVSk5d9oMEEuNr8kE/iPFLy+XIahiKVlUHFRHOwJdr2TS5olDbFhLmMxxWk7WW6N7er7O+ngCz7JAWsSRcId/AJC8TUDa2ljWRa2byd2pPmn904m2qQeJTrdK8fp6yNq0r0SKPYn44xpUTN7o98yDh6JtFxhQxnfzQBIgtlnD0vEvaexZubaWB2dcMRICMyW1s+H8056ELFDZMBrO2aHCGG+Ee331LKftLabEfKNksu9yykSJ7NlDIDtiZdTHgdcJHnax74/HAb/c3GfacEXJ5PmtF7PqnXC87J4wqtiKaSkZ6eYgqcTidR8GzmmxGuhELDAMm2eVWyUNzXhxqFYnXuRu0mRmZyb/WiSoZwnobw1CSyae5amsbXHatxMbkr48zrQ175fErBhZbxavucpXkvNpGVvFEX472jJ2zu6LvlHModN7mHIJLDywwmDNVCXiLySN01axHynnmF8mmOw1gUhsfmAjkjnwNUlcy3v7mHzUmxzkrXQqyk0ETr1DYmzUE7TOXJQocZlSu1l7lUiO2BGD1ZrdB0SQiAEwzvqYI/9At9cpkbiAC+9SUJq9wXtEHJavZdvNyQKcv2t824O1MT9nYpoDdqfGSChRQ4JqJLNdvW+yDSO8h3vG1aoRicZ9tyCqm21qqn+YjX5LpKdzmxl8LCXsXDtYKSQHpMYZny6sbpNp+H6cJ1H0j4RgoMEbhbks8vEZ9Iny1tcF8myelWPYQUa5LtuIxPLhDKJ0GK5iSAGeaC3e83fZbgQGj/KKw1RR1nyjCvqPe9f+RX/rsY4mct5Q3uFqnlubgssXfB2Eb1y3Igbvvg3MRg4o7buPUChQFLzjkqFPJNgETl2WPOB6GoYyeGosRPkLmkHv0Dibz9Zc3bK2+SMnl7+Fht4o0/PO5uCtdNv4lqNekQkDMidgfdrnU6kO6vU9MXbgpUjHe7S4Un7CSIfN5Prlbc0UhYHVDwROcZ5swNX2virBv0dLqC9dw+/fp6Ujd5cLDz0vnnEvIPm3sKj50hDF+0+RDP0pVHt6B0sWeNXHN8i2YaEY2LtK2oOiPe6zjK6DJ5X5HChUzdaLR/O0thzHY0Jj3prHB0r1bQKj4Y0jGa+60m24onGlLyuWFwz2wAR6ioL4X1KeiF16IP9ZfcBWfassZgSJv3EvCcQG7VVkacsOmJUCZDw+Ov1MT3TV2pqF4yFovHTY7kjSDmySB2K7fK5xaBnl/HeB/GOvfM+uDnJ2DT7/X46tfrxIekCF50POOJLYelgizQpMlVV6QxhQSakQGr9RrZcTifSWOYGrWijlQxpKGavKyepHNFI9AXKUEYSHGsyOa6b3fgL9KNe5RYvawWtvU3tDcuqpUZMMFFyood7x5LxO28hmFWpWWr9Y7FFIcNXjdYr+sxDf3crC2BOd5WfOQQgRI4eXLGGfO8gNqTc+MsdK8uOvR/Hk8NmjJSbFXskDFmaQMQNiqopVNOautUEefoC8r2hhZT7Am8e5vFcbssVnvNtiaYfRpy4w8GetcmznsQvG0Ffh3DXLyAe/CUZzHmiltnvrioN3S7mi/bSssb4NaITWmjNvXcfbFuqwFUl1xc7qiJq1csylZUV/NNy89zQepfUTYWCU8V5FHd9JN0LfVFhyAtk+slZ2pFaGp5GrNreCsB3RI0snw68fF2bxhaQajzDlYxc31RmiLW5VsEseGj51MZ8afimaCRWBLFVex7zU0tpgPId23F8CbdchT3HnwsFk0Ni1L8ycOAeWgRJU93AqvyfS7uGYKug153GD1MrNl++HwA9NdWJsIheZNcDlSpG88YYUyVknwqvhMy/eBwu5rVm2hGG4aLQKxK2L1onM6cb7Tsz+UD6kAeYR9rmiVkt6QPQxUfekVa9yT5jK6aAEGiOMq5yrOXvhKTuifgW9tg+cbWWY2G9ZIGtREJ+JStwrKPLRHh5WUkgKhilFbTPCwePHp7ehC4SpZGghwu/ST3LO2/OH5hMt6gCv7JC86C1GbbkMc4LfhYYyEqJD5+QaN70fA+0ai1cCKwdRLCCZZprh5mjGfEplGLb5zpMj2Wka9PBNRuNffROy9ugqcvSyXlQSCFB+VE2BSLyJAp0kKc8DobyUIQbPw62YjmnV0xUAR7LSUo64rn/YSjj7GAkIZEO+XkyKNaTalFt1R/dw+Wut+lDRLmiwfUPNQavpJwa7eBV6Z8ApRieID50Uk94h0labbYD2L2jE+vV5RPl/5BssUjjw+3sJ1uGM9ueoX3Zj/XWtlKrtoeJ96h/Ye+QExnNlsrKqqdL44mM6hA0AuqALY7N92aJfuGwiqIDT/pzfTJHml53KXiCK563Hwy3eKTelYbnopT+Xw9RPxyiBSeTXrGXkRCl0U7z2igSYpKqIPauaxiB7r1Hoi/eASsx7lX8rgYD5fhWa/+rGR94nzOqoWBSM3gV2k548zAzssi58wZCa3QLKoc0epGznf8OvOaFB93dOePfCbxEk7d993nA5p/cCU7Hqwnv0BhfWnFlhCH+HSfoq8sjAK+fhuWR37yED5Z3OP1AUMmDbPwzTT4NrmNmEHZmsqnaSguXVDv/h193WWX3FUtNh6YmN+FgCnjJUEUNDykL+PMvGTwkmTyhm3uvdP4Ac/lbU0r2Xrlu2DO2p0u46IQbC8CmJ+op2R+3tuRBexe5RmkpzjydrRZMsbz+XJYX4wSLyRuUCQJqAoHkpl7Zk1SaXPt5GJRuMdNLcw+B7iOf3bWCZsiYeXQxAvnlZanotEo77ZoIyJ9oQ3I+YLgVdd0njBrzLfX2r49APg3Ol1gXN4Cxz/d2EyQlKoNJbWyWcCqrPM+Ak7bxCDFUEyvUcF4btQ5/MJ6O1G4fPAx+aJBLNUJmHfcPlfAxZuFyQBunAjB53M9KScp8eWJN0Ye2WvwCWN8E976IA3w177n7X1gG/CSQA4xDKOhcHoDRTla/vIaoE8TUdk9GRFWH2pWzCVkfF4Jj91ANuUbW0GGsxxvCBkFpaUMV/opbkwS3mWVNAC/vg83iAuP+/ZYLDQOn9mj/MbYOkQqWoD0sSvDgtw1e8/HciPpsWg+7hZorDzZaqkFcUsAgPiSuYX3CLfnS3L12cTSHasxyOp589xv+sBYJa2Sm/02+bzTsq8bTA+idmN7w5W0qRm3oDMOS7Mlv1on4/ygqNkz3+XbLOdh1pSa0DyCR6Y738XQJHFFoXJbLQ1EKcP0cFEXlX3BqoG0QJ0mV4p+MMijWEpA6AXZUqtbKGRPUamJD67hw4I35IEtVDjOiNxYdeixLmWiZxyIk/8HFtxFUVBFhjnT+/124FCT3IncXQAWQcRXG6OszOhqdk6ViZ1g6UvlwlxcOdhYjqqjmQjwB1mYP6pusndznyRbafzcntMvJAMeZRAZlwyB/dLpXA6p6qJIPBW7sIbzMkzS6p9SsEtSVbAX5ypihCGS4oX66JGuY9a7wvqedBUNJmtFhPsu36IlwgNWashj9mIZlKNikK4woGUow8PMwD+0eWkPTVtP0m2n8D6X4YhVCCxjv886cK8gSFxFgeSQ0tXn/c2iIBfRFNwjeMiX5FYS/OWyvFc5ABbvs402jBjfUqGvsOMMGNrs+SiARrhAFWnfu9iC3M5jKYK+tGZc/IDAN8A/rgVeNWFwTOELExaIe3gCKbsF/An0XyOiW8LBC5w5tyLzsbbBzKSrd87VETCFxl9BOm7YvtGmvGK3TwXxgTZnDyxDOXK6RDkd3DQpC2a/E1RG3TtYKhP81Enxx1CEQFNOyJTnMm7Gbo1QMPuWyKcP7YAvC9dbWJ0GU0bLZI2id53wXadTIh/wmOHRaeRqriTcRKgf9BvD+7sJIoxetbLQtF0sjE1fIQWU1xCw8dA5XIcqUejWAgAHvoBJxnWRm3RkQU59p4fAbBYZAZRH2ujynGKOQytvMnlbiB0ZpTR5SHCzBHyfSRXWjqSkNieC2qmLlGn4BQIWpihpZ5+vSh3Ciklkot4P/QOedDX4KHTQAkbmXSseG8GO1WUdZmZa97iYYhyw7peF8FR0n4QzXGdWgSMfVyRu0413rO2lurfoPHb5PJ/STx4CcAhOzeJMxCUeSlSHdJA/+pkfYLROD53rU+M2l31eVbyX42KUD9c1XBllfcGADA7L6GM1W4LW6Nxaqaj0LBSfHIoXCLPwNU4zeUsxS94aH+r52OAUMwLOdDrn8gsa4NxXBDkzbAvhCmih+S2KQcbAd/g+n1DX8CXCRS9v7SgJnU3It/EbKy9TE9FiLSqArQdJSrq+zgXGBdgE1w3gEyuYrXJ4ToFjGaWBAR2c9bKitbsdHcQSLG1WJlvqlQ4xbuj4ELByHuCuX3xihBZyr0AssKLgpeNyNIta92d2OCXhTIuYxFWE07WWvWIsrRE6h2E+gLxKYpVzC8FBPFHZm4zJqrDr8+WqYsZ3kWxqvshBmZIr9/qCXnhC9UR9f1bEvXKF8nYvBBEs7owjTnlKbZ+5dI7dA4F3VSl7u5X4lLunhfjUWO8+pZsXc/WapNDDzpkFKxDC9vZx4+ljZuUkBGlET0HZVtsdThaiU+d1TZFyGPtLPrDHs3PZ75RbA4tOPO/wniBoN/PQkl4LM0gn1zg4lTAs0wRje/rUxIQsKcmrSoibSKAJEM8kLKXO+cRZ5aQQIsZQTJVWTjV888NG43SuSi9NKUUd1xIXzO7RaWIt9whJQScDVpXEimSMF2WEBNSiFcwkLtzBPj3C1Nlx1LqUpKaO3h8sGI4cfKAJuGQ6eZbiXcRLSyRXEqLVe+sVZvNq6uZjh3Wcx3tG4gM5sVeyMdCc2C6PB+CZ5WNmnTs6GFbwB1LGnBa7g2DtheB585y3ISxQh/SbpINM5eTudEFZzrjA7dNZxLgDENiKdiy9dSJYl8gI06752mSl9jNH6THk2z+gMaypN0NMl/f7tmXJCpgAUPtkMXppLc2sKJvUWZOFxNEHa7MPGbKZxKzwoMsPY27D/nhKyB+oWIBocgsPs/APnxB5FvIibmN11Z4QT0wjT2xiuJov8x7sXEJlW2Yv9xCeUpR3OY31OWV3TH0aTZglbuMju/RttKUm2I5o3Im7+gZCBca7IctefK9834LpaqGx1Qt4zBtP+EMl1eV1XhktLGsDZVF17E9l631fTQFq4wzUxn4R7BblrdulWbnWyqELGK6Zdz7ZtKvPc8RyBoYi4HmH/eG88Ulr/X1Rxfceaxne6MZDMsn43jv0trL8i675oTU88Is9udUAxbbFGCqBvLCasJv6HiPKFhk36l28mj5y0ME87glM1/t8i2JwR98vZ7Txmj+QO5Y0CbfKGsgeT7ZbAEwwXNzXnSfW+XYnfGZY905Owv2iVasB+1LwHWdI3F05IkGN22kXroym3rRvKubeaxpq5UomReKR2SWM67CHqUoJYcVdliKAs9dpW02KPTXyxmucsGfUdknDAmIR/NXot1gK5lc4ez/E+MglwhZxWraNK9Hl9J0w7vQdCSeU7ezBJvXp0Le65S0nFzHfn8yXkFA3JzzDowNn/l0iOqMQ3cXaN5Eqkb7xx2riC63h5gVSXGjCgYQUvrrQMPkqLgDAAyjiS3PfJo2hLlsxqSZHeXFQXNw4xXe4SxHOTLbupgucpXzIQne61dXMX775t5DM+2xQJ4yBfLTHGUeN8dc9fKj7wUF9UOw8nb6KXriID+XO7uIVFcX7ippAfbF1hOyqeM3j9sR/q6xCPcNRguMF+rtuW+I2Ti4awaQZ8H6EqG46YhZypBalpNORCFGpgCLq9I6Bia3aHAVh/jijxRa9ftSl2YkvlM7B6EbN5ZoR3NxUMQkWc3aBzPnuEduX1rFew3N7JYrioBm2P+r0zRHhfGRiW+klI1g5kpM+B8Y4tD+svvG85t/sN4ORHboEKhTHuOVtmTcD0tPhTk6Fpd7LpxulJva8x/lN8O7Ogw9PaNmgJMyiZSUV3+IxKcoN1SGICvz6AN2ec1jfcI+Srd+ZwAme7SwdS37aAZ/qfJN404YI/0iH6X2R9TA/plrIwlNiyQ/M+Za7VNRPuLkEQqNqi6aI9ApSwhwGCO98qg2YynRgphG6fHjhKQ2/Puq5mEWrVyTy9nqpEHmaxHEcpQi9rzz+1tA2rx9jUtQ8UFSXmFfx4O2HG4XeCR2zXYbH2JGynQd5lsRRLJ8Rdu9N4h39B6uwHu2T5TaN/IvfglNh+X0SfYdXciGYgr0JyCXIgd4NUWZ1LZdJVQxn7u+UuSwFnHneeaVzLSpYsnbuBETd96aZ65dewh57NRiz9l3t/akh4r2g8Ol+3xkajrnUX/O7KJG3NQnAqgufr6xUru51hH3iPoxLYz1DhQt+yZ7ZWD4cV/t5Li3S+aNvTpj6E8DWHChpGMOSC+GV3vESJsknEP+Wvd9f16toS9eTgbGMeId4tclQUxmdUY3fcyWl4jk30rp+ytzsQxvsTRKClMCiftNlydlVrkkUG1ut3on2DuvQe/9RHZ29q0fxwIj8d9H3ELSHhImM7mYuo9ZW0QiSUePGaCs0603t+2gX7/S7iavJXTClHyIMaPhCJzQapgZWCukEcvoob6FZKiAcHznOYlhP6ejIv7VOniZiuawehyw3rmuJkxN9qz9kReQ1EE37BLsTOF16SOqx5Xx/oXS8In6PmGKzL/dTiKoPtROXzXwtndMzHzsbH0jocsak2dxEneiS22dSVAATM93nhbZ6NILaTL3dtmFNyHN9hSWuJ0qPvKq2N4A5mv5NvfKbMJ05YaHbz3q3DIkkDSQNyIEhdnNYipNPpTG4VI2HTeRldvR9LIRQoFKGOASWXlMAOda6Jim27XeakcbLDVdg9aJAemjj4ghOz/CJGL3hfemHxfUEL9qQCyWiyyVna9vFNYnInPrMkJ+0przXAZfOD6pCa1TIWc7zTjUNCwjxwYmVoZwF+TmtS+gw56/KyBWBp7GeVWNvRzMK4T0uUs0VdeIEZSgEMAglLiXht8Uh2Ox1FWj9sk6XU6zb2YpS/FB0ql19ztTlVu4hF7LZNsr1HIn99WBRRP4IOugX7nT6kLGyaqI7T+VZi9U4tH3lLtQA/UtNM0jUblqtKc9i5BCKikTZ04VERfyEJ0XncVHdsQP5ZWMDXIIS1RlB0H2EADH30OHuoDNsXA8rMAam5RKCHnMSTalL6z4OYrlzb/cUBKEttVjUOYq3XmtMfjGiOjULo0zsOpVFZ3fF5JW9pNq7QxrXrxSNQa8myYbZGfU5ZAOzs+646ZV9khQSH0+3CsE7Pvctl2AbDaHanhekVBSsqBa4VDAbAjn36dO2HoZ8YXT/kuuHBNBHDDSfM0pe0n1SMcjmUBM+lj4sNjYvDK4GkcJMjmbajaKRsDbuTP2uT/CK+mjXDBPh/E+aruqJIElFI0kXQG7tWgHO19n05VHxgYJo3MUSSFgEIc7JOXwwy3sFFR61d14weF5w9Yfi3M61UcD+qev86RLrC2YfCj151LWgRrZZNKkO+2fXNzfeKrDlJlv+TTZtflfBr2xYvHrIg9WQnTqEd+ng54Lm8AmqTpJN8cEX+IrXlNzLFnlG7Gz8iGHj7BGCD3P7tt5eXuQdHVp7jb7BBSYomJahOe4rJAJNWgs6CjcW+bBIy2olbzOZQ41uY5KRaL5hwVNTGn8Gv9aqldOolWdTaQBiFrd4f46QyhpVAatbo5Bgp5Jqm1bzK0MrNUtjwKq4nn7Fm5m6QtwUgHz0YjTbN0PTq3RaUMWl7+aHE57fC0WzMnPx8sqOn0OKW/TVdOO+KUtLf1LyewOleZ/t0qbzdOnzDbt16jQ+3Os0iFKSqWJwT2CHpJ0Q15DAvKjkaHa4QIRdANs1N6LY6ktPYlyUPaXHjSAyl3/XEEG8TLZf8hsXvNXbOyRImsXYzyIS7MK1tdiAR2CwQ+eShbfLJ3/oPq5DvgK5jKTQII3JGa2eVyK7ODopWhkLxpXHNq16d+tmuxMqmpuTTxa2Wj1HlkcXsbkxCbczqgyhX/p5k8S4Gz7Z81fusomIOyXBtCqBtkHfguAHKUa+Xnq4uJvZoa9ifrWzt/CzdC8cL6nOTcTORPeW2uE23JNN3e+ZHyq02OEwBtltb+49Igxaf5LCOiMe2GBRy9PpunPoN5jFKLFEAR8yjb2wutuVf7nXk9+oNZRiPA1cmH3zI2AJyaolYb3I5l18u9dyv8Cgo+GeMZM2VKcyaXDKx0gvbhm/8YrUnCKueUjINEpCOjU2bsFlKgjAGXAFVmE1lcxxp2k8Ux6yTL6wPuDFvpKzJQdPRwXrxAF8glLj9BpPLy6BxQzV1rGnsIVCtfk1cXMJvetJAq7N2b8S8VtR3IgTZzyeqHALuY+lPj9WcmSp+GOtuwhZc9vd4SRRydwTwXd4XEgMEkzBwKzlXewD44QqByKwIGPpwHLjh4CQ92ngRvq+PKUynEHKB4mDCyKfP2knI9+ZvvdT4nbDuTSfLq/p2J4YMsPvKkT2InLKO85yS+gVlp2k3jt9Vq4WqZCfx6lABPegw4+Bqu+oIVko8dGoIs1lfkqbPVWMO2nb4gY+0OdwsCWNRfdYX+usvhC3OpJONeCU1xYWr4BsalXwFTwThxMr3WcErgPCzyjZDirJkNrZM3ptM/hJai7hdSrORIyfQ+rTP9XN9DyC6qA16juGw2XHzhCsZPY9shdNKGZ8vpS6F+b8oN7DHSqefcUDSMybgJfW0b5Js+vP8AgRIJQ5jyKQoaGingFaor3OxHsjAjk/1dyNcqArjP1V2hRR3uWGNh7Z5gkVUKFNQaAK8uh8lZHJ89gc64RcqfH0wbi8SHgpgDOt73/30TXvcJA0gn44QXSv2JcuI4WMqMXrNFgiozBM9rGozTV0XlKge9NJWQlT3Fs7cip2Q/qnDjls8pq5C4ocqeVKBBta2KnJv9vlPnOo4MKiz/Ny6wLN3G44zeJo+zgVYXmd+Gym0VSNVMLutUaI08ikzRkRceF6nu50DNKCRmWJj7FRtmq7ylGbUjCZRVnvg/ogZ8MkzlfN5FfhKW8r/ImavbEF0iZFfqpunFYUZ/4krjlx5jJfkrqKRfcKu2VPIUESVJhnLkdbdW7EII31uMRrln+icPUM/3Ciidi8oPCsk9KEIZVir/zF5vzzAlD0gxGbMA52idA3GJDsBAuYjsLsgrCVKS9J4zAVKdzLWXckdldBpc5FPt9LrO8vKcaeRQHW26f2NT5VRS2zA38moZQuueAlAMvK6HrRCfOa5DzPL1ZBUS/0zVjlTeOlJETjVRwdex3BPEE/mXFMsim2nsPAzbHqmmbrzc61NhSf28Y4chCkWbeIwSV3bv5mccOtvfgTf5X8KlgJfPTcIf7QZBIghZ2VwUCLpttjKBjpAgEuAo4j8jY3TybwztN6lkL2RGRzRpVzoBIfXnMtypUikpfDXtaD14zaE5M4tShb0WCQocisqPDcJqFYks/zzCrWZtoVeLjzHT3c3DTNLzzD3IY8QxF/wSIHeP5YA15+zzjyQM5oiQ6hPSRiPUN+dtiqJGm+L2CoVDqe+crvrTgmp25xfKyH2Af/fQ3wl2WiCf06elwMeUelOeekic80iaUGwAOtxvEnLkL49pSZn57JulSG7Hn70j6EJgm8253cLkx4c8bO6U4r+kALyJHN7cFVmnYjntF5zUXBBUxF2JzbUtiIs58Qfo+RvMqGjKDn8VBfo/i412xhEHrdPA5nkC/lzUBUi/efWnyk804M0Vmsm+SZoku0Qh0HV1QqihGK+ytOt7Bws/XFwgv7liRhlGAsPA+SbN7MJfZJnFRy2hNq368UpeYjJg5sfqAT20hbxCVNzjO2bsz8rkgGzcazZL3P0fKb5JfO6WKwTP68MFrRWv5Lf508j+96KQw8d+3e5znLup3dWxQEvqZIxcVy75Cem02W9VIqSxW20aY+OOwEiEFDJHdzipzPMxebHpWapm+XE5BKBM3478P0V/48GAgxe09cPGtZiLqjKhf0SaHPXtcwTGo/eXsiGbVBlHRjlKlICtkRI46IbaBhADlHzDR3qXFcnfNHLUfxOL+cszvuT+SieJVQRrrNl5XVOTYnzQL6LExVz9dMwibS4kUDqQL6vfXbEhKqAPtpRcnpe/eiipPl9ipJPCIsOR37wsPTUJ8YQJbg/reCHOU71pBXXgM+3aIJlR6YhGWh1XK3en2AfI/5DVgLUjTuUM9gz7KElef1hke0vYg96hp2APcCA1F8XGJ2UU+V1wk2DJLYceWVeoTn/Sowktsf2MBPO/pcY4DIw70bMnFBD/TadRdTFtvgEIsr+1JS0d4J3UQu3fmimMSJtGxvjHL7pF1X9qNO5y8iXHN7Fe8dxNU8Aw69YAnw5KGe526e/SpZgqLKgVYKraCE25/QSMEKP06lsc0gDyFanF6UF6fe3NJ7XsrQQoWOmKKn4xu4w1MIXPcBxAgQwwXUCO01Z19gfZJLKHUv3Ia18oLpCmJT3btdXGQ+svKZfNUj7Tgbm740boitUfAJhFZQNdW6c3t4BTVLp3X62AmwtxxG4Uzc2aN/HVbpSDidaiHk0BNbkDtWk9x8toeLJs+VFBUyFzunBkvy+SoQri0AihwH5E442tsJGrK8BCTRJ4UX8xCrxPhiKCvz8s9tIG6r+awfS+M7k6MsgB7zrxNSHgjCYynEuUycerKFN5fZxXGoC3xOxAYir5WLsxq+1GNbtZybcDXWi4/dALkASUq/tI8zIEzCbErC8Uq8fSXaZmhVuyO1slHYcy5E9A2eWsuHPqRHmn2pvg53nOnq4J7Ay0179W1JUneGy1cP1l9gyZGAjoQkp+1anKJWEBlJ1FphgIpGCXrIYFxRCZzhHA/KjeLNpDDUDbGRxCwvz3R52cdL1XIhh6XfqFVLZM7vWQWrYSKqAY2cihgxzqccJZAiVaWLMJ+fJwIZYnMpM0KL412ty7pBmurY3FTxnpZCJKwahboKFx8x+UnErcxqOpUuVSuJYSZu8rrZIyEgBIDfWmDqgGvjUgZ4KaqaRykQt6NcbhpMPIA++a84tXeqiEZaT0kKZteLhXuazgNSYpfDRQT6unqMZFNUAk6BELswXiwz77WFpAdbaet1bgLOc8W0osLkqpPSNkuprYr5HXLjtXyYmDuwSWirzxODHypxLosT0Id5UEeoPNhmiz6jKPPsXDXMhp6DNwubaoh5SV4je5viO9m38srHLXEAbUm3DYylaNLfe14Ea8A6r8wzkTuRPFFJw3N2ldq6dTUSwn2Zx3OHzzBiyW0QL/MgkhPGptxmiCQ1HBRDZcJTmKhTE8meBvcfrAegU732Ua2qCZkDE9aZ5SVqGTo+Yskk1/pcvS57Efk7TbNdfauEALC3c/6MEPakrurTPbk3aT8shyxdHohQXa32M7eVLT8MEbudi/GPpxXfTxJ2WLF2xt1IzO3aKDofbNbRUwc6uKFhhlZ5f+9HeV6veheR9ufLoAvv5w083/aeryo6GTDTYJ8VY3nDNhDc9gsTni+OJCZIedyiamoBKV+niLLGxKEvGnVzneLSTFEuJup881X8bG2Prowcm6QYJVXi9CVvpQxQkETdwL1Tl1zzPvbIxfcTgxkPCTyDQhaEL7uQ6HFhYeN15hwgFp7iEzBsq1aP0VkDXV/YiLWmRiyJiB1wBLGAQE4uQnSabV2XiNPpeEhGiRc7u0bD+6yIH3ESESM+1CHvEicBrDWwd1lbY47Ckgtu+9pq3Z6yUqSuAom2f/Q00E+P2q5hKSbWQSCG69jgyNTssuGjKVsrWoeYsT2KNYzo7VxJH2cWQOC8ebBEAh4cQxYkj9ul2veL+Dg7HCx4U/wSOod4Ap4Va9vtadHE6Im936udBejPLLtb3KTkk7LaFRbvcr8clrW0iQdkJvQB+n/yIzFrwHx8KjZG56lXVQSpheZeIzmZGiPkmLqPbLU0ED7MCYMyhJ+S0Lsk4jVLROMWt0NjbGuZWCECEK5Y1Iphuo7AefWJQ01hbDXEd4qunyf85WhFdeZIX/B1Q4IP1F7EUcEUdxxit24uO+MoBWtHcv0AUgEeV0dF1ty61ZYSvNunW5IU0WGLw7lDZlmrTTq95iN8bkQz4cpnApoqBciM0iyCL+tIJslAepBaoZrxfcg0dlP6sI7ywNFrt5jv/KFYA8oDpZB5WjlPxLXuz0a6CZ3Ro6kbmQ8RMYrwxEFWIvo3cmlYH/BQSnJuksdindI9Bva5DGxEe49grQdN7QZFC0V4KJajrpQoRdfmOXNNWuO5L2LAr7wpiS/mA7nnS+N6C3+KfI5XpBMszMbKC2kk4jZwD+KMYhLyygXH0hsX2WfvMdvqckLVxzr5PnWcnn3spCw8ce9ROn6h9qTR/MDSlCe0JazySAoxtufgPpy5ZwVWfBSeBr6I5BELqWoXEQW+yyFRRukg0Qa8kC93VWNr+rkGlrX3iLVeKNKiDctTTK5T+nNKiSKB6VIz5ZOVdrf41bJNCffE31lwfZ13fBdAyNNMQzAN0MO0HBe0lSvcdtLxuURVNQwn6woY3bJUNrZXlrOII1Drm0icKk0PQLTDqhF+Bxq2Ey4S0FGxcTsxS7dXlQjL4ZZuXXZCITYwPYqSPVx9FrdBokp1ONT6ijVArxH7k2RqjWMAGYNf2fqInFRRS6uKC2pr17dOm5jl5nokM8D66H0HDnQXV6x62U3SFLhFBs45gydKbjxy+Kpe2Qza45KUzk7tUHLh9ZpTvTygaOVtQOf4UFP0rmCvlrlfh4+njHgf4aQtT82ZO1QgC4sejaoorpEnkgSYShV3xJhzIoxd7ni61/KGERII7AFmFmlz+SZ9WRZL5RJAPrcvevp8ajx46vHC8Y7SSdBl+NduA2GQV+IIHzzCQhffWxwpK8S8L0+L6mpUQZLkWXlW8XLf+1H3QeTfNe3XJfeotDqCcdgWFpPeHJEHIALPm0waqQB+ZsH4tQn3FcBWQ9CMe/vIgl1vLjomL4Pkj5clYWG9UDXzO3Ep55Ih+HAeVuQmWiVJtD7+CF8vT5dF4UifXFxCtRaf0rTiFSLnfEXqmIwhQ9LnLbiXFTCPTSc1iW+MLHOqLFWCpXbPy9Nr0nN6BvG6JHho2e2xP/Nu5CNtB1zDAyJUhCZYv/Yk+lHQgDo2QS4Cc5AR2Yabbk0Mrw5c5hdZGPR5FJcGoW0uQ2cuO2XaxYaPtnb4E2QQhz7T7nmSo72LSAvlC40vJd3nFYxqdO24qOYELBhU8mZ4IAIEQu5ZIEuAK7WLRSXimevgYySEOvNyYj5lqbhehYFJPatFqSRclCnFvKZ4glH33WGZkBLJHG+o3jY+SCclL/7RQLYmQyK4iCWWL2la5IBqKuf0fWRRhew+dhlDJKTz5VUjL+IxoXcBmFoJWpCM8LU7KP55UU6vRWGqmRdvbLlLmfUqG22/9I/qoUKpmmBt5QPGM/s4M98SSgGzit+zoszhbbm4OJVVC8aLB1MlGUdtLjLckxCdHbLBfXH9sLVBRp+F+nVmtEhxPZXOrN2up56SyKdh5iNdxrvJP5wC7q3WVRf0kRQWSH+OC3bp0D6G6lCLJ9ZQ6Ov9WCZt1wQZsYHHy9XitBkQ45yy7SdG2vxQSg64E3QId2ORWQZysPfDi9zhBjDv3sCHyI8awbpvnYQWlik1YYqct0DxWoUIVJ2DO2nXEK2ddJYp6nB6bC0ltW7gv3kPCW5ZaCCn2KW2FVVxJtPEx0LgVcnbqxj7bZshKyb4dKzdrB5z5owjLq7dqeDfT9LA21McNLKhopWp+U8Ho+92IohEN8Xr4Hou191MSh+Afh5Qa3DaBjiXBjcAb+4C+QTvvVDPi2smFdpQIhS7TS4dp4V7Aw8FkQtBnF4GJQ8e9nHqieClJGjj946OGnIjyMR5Il5khy9ecD9Yl2WDjrWe3Ti6S+5YD59HT8a2lkRJO/rMcQ+8tRlyWGWkkkecN8+LFT/Z6GVfknKpHOxxOyj1NFJmn4VcyqZHmYYjWoviBku5vp/IZ/6mIyLA0EYfjY0HiO/tnkDDRwrHHscPE7t81IcXqC6NupI2LfJTTYMqgh0fRuEgNIcpH2ybXQa9TnegcwDIHxmtnPDG2pfkxKTT+wE1OiSVTTGkIg/7R/NgIilAnP483kyOteoaLeYoR7KhkULEKEs7B2w7vRBX4AayFGuDrGnX+h7i0bIOpcXjMPMg0mknvTJZMInyAi/yXkcwDw/eurXz+/QiZ0+X9bp1t84NRU/FGzHJarFfx5aG+x2wbjviu26Ou4gvdaYJkzjEV3i6DwoT27varTirjgGrkmmoaNSScWh4NVm5nsUQFW2nDMNj6+/hzHoHoQ6JIfhZLtGoD7A5+sDmM3JAbHaoV/BCQ+GSi5fxtc+iwfOAxO+y83ji7WQrebnIpJhgSJ97VR77lXH1q+46A0Lm6hIuSlwrZyATwlLtSbgTfkdvR9+fsVGzA1qnSswBYD9LDzFHhZfJVWfN7sWrOBxFD8uXStdQrXQevfjFk8wyb+gcQixE+7CE+wY5Qu5+6YyntSt3UmBMdDCc0uFNX+Y1361NTU8f2n7TYz+ouuy52VFUzkABJtPMmpiasAbun3Iq1pqIMtXTgIjslORAhhbK8FKVh7djPV1eJKVuJpgJQHaIr0Kowb0xtY4RCA+dnhi6DyzNPuObd9fOQvsm20KXYnMjcEEWwHC7dUSLXZyQu1fbkp2245gCA7P6fO9u6OgM8fwSkSgiV4xFaNfjrXxGc59f2fRM0S0g7J6hEXRTAiAaH/Vw4k6qCMtt1sXkcC0zcnhYw978VnmpXbJoANRUJLzai4VjKEwDTppmgS8kNgIgWE7YYy2wibpn+Dn3qzIZcHKHp4/lLsk3e/QQXxyC+TnxTy/oyArVqfd2ZWTXycGivLFUg+D6UYAEoTCEVTVcKtYtgwa76AYy59hNv6dW2goZWwvBfSGxMH3ZN/PqNnwdSfKKS5BH7YowzGvb1W6X1NKxTi1HKRqWloihSzJ5WHcf0868DlfdLLoCX0lRsDbAtrYy1ZerOcgtuXQRspHwOTI5dksA9n7YU50YzVyBxdPQBM2sWHUUxRK6avowanszao9X4glRIi8p46cuodt3LxHF0zDU8RTPS2lhZWLwHiowaPk6e4YsLgFQCpVzsx88Spy7/gW1a2CODMBdyDnmU91hr/+PqutYdhTZgr8ECCOWeO+dYIf3Rhhhvv5V3Z7NW9yJjpjbLURV5ck8JotWQ0If87AmznC6g6/JzB1XxSMLUDqxIiuo5R5pJrpjiiMu76WnAE163odQbj/pbVUlgUpiKIwwyCQZTbN6ZgV4wvXObeE5EtOdc/OBugZCNFRx26GwHvM7jmNn6BN86ctx3zZ4/fVfME/3r/Cm0+D1Jjyg22G4+lc1kIpqOqIV8SdbqE0iuEUVt5rOZi/ZOiiEKz+I3GXe8cQt2E2fRzoXIeg7WXniJ/Zt/7jHlSkqfXWThOd/c2x+iauRMxfA5cvyEPZADfZ5AVFCrnWAoo/rfBHeX8LS5/EjByDC/0jy6cYD/cfIX+hH+z548OJttk7s/MFO3Ov6DwM0SCPCDoNX3bDybxC4HOzCVXfrRhD2VsDVv5rLTCKP6RybO3aPfqo5laUoYCi4gG4GoO/qMRsRtlkS4Hrn3TMmA1Ep78SutxNmarjhy+tfPA2QvwIGK5dFEXnrny4/vhtRaEVbTcMkbcHK7LT9651x3AJnk2asCr1FqD1HfQtW50VIt8HeVSc+tmaPi7usyyu31ZcvjxG/aKE9Mp5zzH6q6b0fx2ATmMh1Ix1nQwbzdfsjrwDS9tf08mzfyLi7YHEgwc9G1rwLnZX6a2AI0pSGf1DRH4+zgwFhxLd8quPrubPY17JR5wDcBoEzyL6mMnWmebNHAVJfXnSKROe8cEbiSs1evt9RAtQBFZShCgQDEUpIx8QFDUjr+4aol3Uor1X7QMQp5l6BwXYvPURvi52MBvOKJSsfmiCyyjzU9POiDx0nl+Nf5v0oit8VQ9phuOtyM3AszCq0qzv/6qMOJlFGXPFvED+S0sVvM3HakahR9YYOTT2OnSgBuwlUQ1lUSU1SpBKcnxCe2IT6XyjeqGHEHAp9ZaphmT6RaaI7yfWCwvpT+sySvf6QQMg9ZjKedQ5/zfcmw39FKhJhp4KvlZbTS/54KJS+FdjI+KJgFScS+eHCfXr81lYWz60042CzdfEIiVTIueu+oUjK3UMxsEqmqUYG4ubUw/1jWDQ7vt6uwoy9cVsrVyVG8obVmldes9iJTKtbSIpdP13S4dKQfjxOT+pOa9K22WJC0YMbcsDpi4GAUW/sCZ7ROC6/WzXq5mutPjHremuA9xlKoxlKks+Y3/RaFplrefKUFFejEj7WkvC2euciaXyIEtBv0q5/ESJ8hEN1KXGWu3olMNgg36w2xjhk+qK7vr76s3AoVwaMckyklF0puRWE04DnYviV2IMABVoX/7IYZpFcWU5xvk/ioSFfidRVyjR0c04y2q8ITMXk8u2X8i8XiN2ncv0kcTqgE/kldQyFmxMt4t7iJP3U+X2/OVSlnQllF5nkoxggnRCthULkrBO9xaa3zAjAspkAVfKx2bznrW/EM1vP542yI9yPKFIXS+R/2rv7Hm1cNRx7+YWZAsE4L0XIWjtv0dZgiBuc1mznGOjIY8wc2OAk+Nwtf2Sdn9ihkDA9q79iK5Q9bDuQbyKPXklx8DHM5wfyJH9r7bZlNTz4cT4SYvP5WVq7/nYsWS0LNoqVh9EiGmEbjd6cZMOH/uAn8IwQe5wTBDWv0uPaIt6ImxvhDCOs2iuEpTtazhiaVeQEDzBQEhxM1Hvu7s1VAuL4MGkWetq8d0lZsKfFY6/BtQyn3k9GeqIjeGc6LMEXMLOC+ZOf0OEXMOpcgrwW6pmM/Y3OO58rd956t0kxpuS+H/RXDCJCljsLs6DccXX8rJIomkmvQ3t//O3Xh228qVIwF7MKluUn1T7//gTiClgBjsybHTLIHVp24PMG+OFyl+OqX6QVeg1Tkoe2emmq0dI9RpZLF7jr74f8tSvbADpCUFafH9EnOXzUMZVzLhrUgnLUNAXjME9t/c5Q6qPoIBfrBh6Rz/UfRdLcLpx6UC0/Hs8KQzl/7psiRYe1RUX4Dgo1BLNobrb5G5xtAXRFv5VKPvmm9KTRF5ePBfAVhoO06AOT2zdzjeSi/kppTXxVaTq6pel/YbZNqf2xEZaKqEDs1Mk+Jn64T2P2coSNkw+34LsyfwzjTpNC7xQQOxrlN9UUfZw1XiBJLU6NyO83CCGpTHSF9Gr4+WFr9XIZyyU4fYjOflv89Aqo0V7Th0dFLUOHrO7QLasIodYQuW3axU2FH/PdBKB19QkwwQ872thGO6kvBGlDT6/IMC709aBqZyx9LAL6Alsl1/WKEoGvgCjtjhn7ix3CC6GL/gXUJgEW1udRI6XAbz+NKNMIckv52A7mIBnByOWwaygbnRJqDYwbPXzkiEA9rx2nHwCGdPR63D07Mpg8F1J6q+iXHGF5AXP6gAtRbjn6ZANYtpDtfzoTYWl0VeeT91vOScE/SfG9pZxdj3y9Svz1dLWErsAIsRt+wrJCMse33NsseF3IYEqtLu7ZChr5jXRyfsXXJ2gO5bLtyN9aIEVBlONHV3Dbk56dLWPGv87md5sITlEnKMoUYEWjb8pg69fOvySDA8IcMgOSWI7M3HNnbRI2nKohDnXBBnBucKyXXnYJxaoGdJWLDfnLdxtvPv193k4k1/Moy1z6x74mOa2Fi/7xrPM1Gk7rC+vyTOXLZe6lf0tsU006NXtb5D83nXxXGlLY9DfhvTq4Q6XXqqhUWP6qeqG1Fq/ke5jLQAv80g6UzrENsf6xFeLtJihXOJMlYZzb2vSRz+R32Rno86YOfAR1cf1h0u3Y9PltFgbUzPN4VJLj4CEDUSFyEokcikxzX1sCa1CZITpN7kjbT/DmQ8JfgY/7hUEfhMtmH4KNgbDnyC3lc5it28EujKqxNbrJHe0IfhPTweSaztGtzVX5O5RcsqUop4Qk9bnGkDfIFxB0zsiHf9VtOwjR30R/ajcPcynRB3lOvp+xdBIB8P0kzZLA5QjXBQCR3pY3ybxybUf0Dphmfsx+7vcuhIcVpX8qXEHtefm7kE6anNp+BhgpgurV0Vhpl4qMb46ftaPG4+gEMokF33E2KyfxGFMyXoP6bbX1L7k/JcfI1NTVL9wTk01lGRIpHpEjvmnCuTn7qB6AZGUfpZlN2TrkxUtLRQfDnm3hW0Kwv51P+Do1FyyX+ka1lMWFdy+/pJKjMiLBGnK862iYScABI2C/tEFus27NsPgQIjEqsbOOwOMEqMTEUBo52D+n/zu4zBjS16B0uAUiPP2uo0ixFHYy58XPnW6goSBUSqr5VFvpKx/t2uKtepBshKc4dcsdTqQetVhELw//jk3Q7U0ByUC6AjpLpD757Kcd8/lFzweIEd8nT6LrpwmOKXMDpUbqjQeRz6r4sZ1UfUONttaXRkVdKOi8do66VC0UhXNxnRwpnoqwP5llUu2O0A1yQiphGVifvciZY2Pyyg2t/YoDimueQDMwBfELdOm18cbg1lpRxNRWzxx2vgPzpWz9bliVlQnMOYkN4Mf+it5TbbcTXw3fkq9CwAV+HsJBp8GWaMofAuKxEn6390icO5oKDM/VcZeYtNWOPq5JhdEtymnAGCZExhHlqkR+qUVcNXWuNwM5Ep3lO/aUY552qiJwgTgvSoTzHKmQun5nLJRD7iizfVvnYc/QB5uR7j1ZowdoeT3dYUtbwU2SfCnAqpKeA75BV+Hp5ylAf6Ma33R5eaqJBMzFaK4oKkMi2hkQrxu+UcenPKfPR/Iuhwp2u253+Zl/fj5KRRFUH730t4IbYdeGcDlYsuo0A/bdd8V1HjUDIv3rFognn55lQpfrJ+kep5T279xH1p2tw8BRLGxUO744VecwEzS8TZtCCZ+1ztGYDAw2p16d/366F2W4njuviWsO3+vZkoZuFMqSK1nxG79DOScSnEvUx/iW+zvBCb2wq9JGCDwUqtNLtX2IE3tz0JJsWnciDlz+5Lfbokr8kpLxc071PXY7as0Y1z+6tyfgJb4X6m0BlIatrywH69ciCAgHw3F8y+GSpvzNIrG/CSFh5rTALUXEVQUNdMXMJw7wAddAjW70nvleIoO65aejTzPhlUWJnKxjpP0BIhEoMsZ+PNhEMVbxbjGThCO5uWHNO72oWDScNZCLT29TyVqZH/NCFEmNgAD5/ZY+RMgilx0QFMRSXqoUxPzWUbnrYDlHgnvePNFJPTOnGs3T44EkeUkBmoWxFD3Jb7EjSM4G3qQ0LsX+Or9HWKYHr25rtLX8iYuw4qfrkJJUZD6JcnEOHRDMAKdYJA7SHe0M9GpX2+MMXeVEFQRDpJ3JzRTRVqYVyOv0GW8MLlQO5i0WDTnq8mVob+TN91HrGZ+E8hFi7R+AqhXWJY1CIMmJZY/0Cmy3EVa/UjWooPJvMWZRtFpq6Pwsq8/xjMcuejMTmma5uc46u0/zomlek293qBglHc8bAIs3S8SzOsM0nIZfsDHYULk5LjcjJZ3otcs5ApMQcoo2/6VgrO50o/nigdqZVEU15nGmOWtITy5u2rw+lpQlF0ncXLd0/CT1uuNjujj65cnd8om/nl73DDPoIq0wrOoKYvBvjOT1peOWFPd6i33aiI06WmIThAJBusQ3L/+V5otQXeNM4oOC5xne1SzXHl7t7r83IfbQ3+kIpxh0G893hmeVR3B0j/8W6rp9Xjnj3iDIhc7FCZWpx+bW+DAPLaJ8CvN+5x63eXeku629zOpsTnwWAPWIdU0g+9HVUS5BWTjQAbMCbRxyk88E32mfFXCYnMLefmxhTdFJHQl3soHs0Apszwynke6QWoiz1+eDwTjen50h8bpp+DBnWmFrPOlvLruY40VKQjDNskX0Mmb0tMNPy9a850R2EkaAA8NsGJyogBzY8a/moZtWo1vUaqD1Wru45jYgrL3kvFIIrYBl1lxKh8Tg9abzuZLX7vTUKzstvZfQx+i75CozpCblEtnIqkKUmiqNUY/3JpoW1bmeQqjlBLlKG6e+JwSP2yjaHQBFgN6IVK+qDiIbtFglS2p+Ls/4Ko0Pf1/ZmWQP+RUZugVT7nC0fkwCJ07ZUaBZXuoMU4uXAKp7//3uZa+xOIJMdKWG7+Rj0qjlzW4jh0mavKbVMwEAGSf0EjMvK1Ejj2K9HPfs3DxzqQSUkplkXymgNndvV9mnHNDffN8mz3wQMszsRyhO8LAzQ7nj+ezQ7YbtQ1z/OLCSmO+rr9A7gKQFQz4gHthZWH9lGHyzxXCp3zeW/CG2DfyxY+8S9FqE0TWrRaRKHdjJxeOC2H4mR2EaB7pDsJaMclI14EAfJgfV+tVVZqO/zcszEZsUkOx3BXLHhk3KxFl196zvr1GcsrzQhTXvcvXuv3C3keEkmCyS8MQOvu1awgw4G4GjAs+RvHK6k0rDBp3mSGN7Xwnpn4EHZDKGPiFdXRvMLn7kstjzt5Oz8BsJT26ZBFrbLJppuNZ1y7XNQtqczAJ4FBHyWIuDU9mpSyKaqssZ22K0zNX2sUeY0vqop8o3vY76ExxzGaQd09jAvp6Cbrg/TRk3vvnIv5oMGyJvv04nBXFSb0RXY4hywI5eMsfRD5k2giAmi/kJDg/fmOxUjvA3o1Ou5qdRZ1Xr5HvIzHvzzAvBKYmBr9zlXMF+KYFn8HNfj4VtnwQZMtX5pAZqdQ4vdhXtcina8Cndnrc1aFDdi+Hrnz2Y94X/jb8uG8ZVMgsJs1pA/Knc7DRc2C6epjp/vz6gXT8L8ecTBWN5C3VntLQGT6AHQUM+YiS++OBl2tU5g63QfiP3IRKzlzv/gOsPvYQC4dln9Sn/muPF93Uy6fMzPDpe60IG3MhZ3cJJ/korz6PFsS8vIx4+q3xI59sFq5uWY0Dyi3BgmUpYV5jEY/5W528KPWfYh/tp9/mTu+WubqOuD/5HF7K7+j3nL/l5Yv7obZjfljlX+9zjObHkXKE30o3xeiSTAWg92jJj9s426O63xkOuidTx6P1rfP7m4AOYlmMFnET4qtntjUNz/PouKG+gc9q1w02JnVhHunRHnoAkbsqWlUEaSjc5q8gGprqWcNZADClLuVQ2MggUQaUf72iWoj+eJpy7mTCvZmLweqrnNDRsMnBYMqitNuo0tw0fgDb9/Az68PGEOfJ8wVQgEbVtTimlDrn1QtIGnlSIjNioWObnyZ0JI+x2OWXIr9g84guVpu3LcEzsONxcmNut0n+DcZ8SqHSvdhspLMyLuiWOe0DUdeP8r7IcKS9An4wmx9y8YbbpvAeg+z49fofbYtHt30QRExEj0A5l7mNkMpIGVt3SuVl/A+WVNU6/vu20hN+ZAk0fK99Vqtnsj8GrOf5fP6r7bXDd102euSpH8ZW3xbbgK0HoiRDvhSCAL+u/zwmPuBxKp9KndTkaJuEk7m64C/g5Z3ku04x3j9jaO8PeVDkxY9XxDqSV8yNpTlXW7D7nhdjhB72ZbVH5oai9shfq9miAtfT1komDTIRp+rwCa8vjztziqG0CGTN/rTaK9UvQrnkoXmW8/fq+9q0G/0z2iRHTt7RvpWZaTkYL8bod4VUz7Pi665eCcGY3P/5WYT63w3fSQtOjqY/bsO+xoGNdB+1bt8f7qJ9HsMr5ObxMl7eWLyrhPSQLt8y0vUoFQtRB38CbTXigJQ62QF1YcEeb8x33Em58y68VEQFjwHV5PUjQRaPkkYT+Rj9J3Am9GA/Lx8udL0shH2as5PirX4OtAzUSemR+nYcwn60ri4Bpu4oG8GZV2Hye2W2e/f2R0e2fMQPbjOYiWlnYiU4IC9u9xbAy28chy38v76QLptiDh8GstojaLkjjQ/Luci7szJWzT3Fbd8BtaSLhGBJorcMqEFYilKhkQlD+ekU+7plPrIlHTL5GxNdtPuN3dBbS58mTNGiLFQqbSIxhG1+VeUy9HRE97RF+/wLMM9AKJnNsWfsMXRB9yKyomnKgbWZnsgI/A5gXwKK6D27L/nPG3NqNdTQ1v63M/Z6bWsGtGQqaXA5fazh/3oG+5O3jhN1BF7bL4XLl5DydQH4hAmX7cmfY6tEXRrOsRjJ/pbkLKm9K5J2dCXE7QmeeGSB3xd6U4Au7ECDGO1o28Pa7HbDOc76qcvYtS2TmjYGdfQkW63Pnrw+l5E+h5EZjLfUpcjNWernQ1OzCG53sx4h0TLqZUXsN8OWwkBCxcr0ud6bWYcsK4yoC3yGn9dE5vna5SW5qTfVUUYV3UxgKS5oSu9TlHYpKP6f7w2MBAtRa1hofqL16KW/QtTAGuodutJdMyr3VHE1DOMihE2CngkgjLxlm93TyTHJgBeVRzWa0nstx9b33Uf+a8FgJ2ZA1qBLNRe8yjO+s1BXt6bfj2/85b0MKgVCfP0dW13iVdeff+6A4tTSgfIJtsD5zDeBznsorRx/wUnSk+g/LKQ2L/N1EgNmv1/OzQj0rpbcV0v6jXakjI0Cfo/ID2FI2Ke+8oacpsgx1QrM/Q5XAteQ8KcfoQYSKQnPYdf0+OoXUW/FcnBZHN5O7jca1+GK1KSVj+0Di5aX1cHnpYVWyX1UfMP0ITdRZsroUQfueaaZH5VR+Ru9kfTw3+95TjL0ZD9YOLpR/o/UXzt38Yv6KMT5wq10I4Htt/0uglf1Z9v5kB17hVQiWMA9OBPsd6ofHuI2WFCZ7I4I+p0zo0CWHR+31N+1lrULG5FiUGoVRvJPeVFCoOmNViqBOx5ZDIdBXolBJM8K+A1OUOvP3bhwbVTP6kjK6xfbazAQdsnypWOiuH0s86x5m/Vicu9LcstLanJQP7hlrOwcuVzvFnKRu4+LVqv7o/f2FI+Zvt/XY3H5nJMvJdXC6pYbQMAlpvN/yh3c7grAEphUeYyakfIfQgos7C+8vEg05IZUXIl+lMyqLgHcfKsx+QO4sP6wRrJ8pkFAxYcEZLF+1L+CzGg6e/rlZ9LSrTZNjrcbPkfRP+zdBIlILoG+apAedAsdWgojvAwF3EwZFco3x3yBE/Bil8TTzCnV+dZ2vAjQ1pzUt06dplglgH0jp9ofHYWNqXPTq42t4BKPJhki2NK1pJPhJsK1XrBc0wpBFUAH75bmZGgG+Y3JO/GUKOkS6GiDOWvsAPxDiU/Kdezxk91asssb8+Zw9CljcDKIDlVXiL8k6IZBwcWjRAMTXimnf41JOHzgM3TU+kg2prwptXs2LICC79CZdZ04tX3AeooRjFGzu3iOdpZZHi3ZNIQEkeXJu5sqLkvu8lhw9dKYqcKU9tnP6PXNb5wWWT8VNcyJa85LqnnSilRupW3GbXjO9N37Aj0dox/24AsxqfVjNxZTTjVgHlgqNBjYpl3tyb0SOwRMuCslpt5n8rSXV6b6R4yXlNcC56DkILfhyo3PKSNnlblMxDTE8o3jwwTsq7gh7GULOLyxk2FvqRB2idc1yAO20N+T35f11fl7BE52r4Yj2L39hHFa/19yX36QwKop9zq78ZeHsXbIj1IjJnL9+JujkLACY0SuLRUA0L1k5w6pthiouPfuC4v3fDNZkhEQuRNpJdy6vjc2g9tU8GF22o4vgMVctiyQzuTB6w8gRKNG9wK9nXWVJIank9zFIp7SD+/08SC1p5/HLyOQTiB/WP3eKjAwXiCmkMcHziQ3NGrhad/0DYOebceH43dFlbzw7UdnpLVrvWAIbqzt6iWXCJ5lcYUdSLNFr5RpUz/38TuSQ0O0MjI921xFp6E7CFsF7F7X5yTQ+AmEC8AlzwnS3/ppiX+qkNfzeLm/2nWJIXx6tXvGc9/XEXC/bOQDqYZzYdXW84mMUKs+Ylx9YoEpdTvC6vQsYyshH9lKVnucCgToAfaLE61g+6XaIIoCWZllE+UK4HK3xwf1Zk8Nd3sTPQTLzxzrz0hL5fv3OHiQfV+TZbha3fabutNHN+P00RUU4g2ott4GHJ0EFkeu42Dg5nTwtWbr+9bZA2XcSL7XK3oDrJ5GQf5dPZh5L++vCX3zcRw+Y6suga2OfEDswX3w51aLbLg+Glh+h5+iIw9Yivm7kt4nqfzUixwwyW2uyJejDRsePPgrbXI9CoNZhvd+CdSy33VeEsKBasDru3+gr1wglUEb/bmNoDH71YukdVWz2d4eA/zMHuW5kYe6HPkaCoG9+ypUDfbzG2wuHw6MvW3GNdWz/qr3IwyrnyJsTwGpL/1B7caHd/m9SSnN/b4Ou8AWbMMlZP668ZyY29Sel8RNgEldyBSNBXmzkKKsvm5M7wtkeUY9wG1BUyK/38CQjgNP66Vvgf++9xX05ZpftIeY1oe3dVkg7zvC6VnG9ZN8Ngn7aRDVt1vMimGol2hczbP631fFMQSeD2HvJ5qHngRhcNBMforZUDrY7ThpSrWaPOe1TKp2Qs0yxM5KKtMRI6YPCOD9/ut6F9r3suO/Q8Oia/81j8pxM/1zGYZpCqv+aRydV7iwVJbS965k7z4StkzwMYFSORJcInYTfSRQ0ksQLRXzrTOkxAfUZA+71aQCo9hLZU1H5cHwu3D/cAlJ7kZXnZ+cFj8Naho+9SNx1uhWjQ1ZMg5eWEkOwHecXe38cvwlH/tOcXzYMwjr37NqPP/eejqjMdwRuvKRi1xqH8d3AOHPJmgRA5uek209XVSWPuN634T+tUvhZf95977yd/i02dbG8zzdn/GU4stClVCQ2xh8K+ED4zxmwjFgmcOMbTqLzQV4g3HQ7mq4Ps4QQf/1LIMwjg6DVacjHmmhHTB0vRBuTCwKJB499mt6SwpLbhpldhpgph1g+AKlqgV6Xx1q7DZQ7w9Hu2wrX97XWceWPFMtT+OdwzqrkfOJpfX/J81m/MI9lQh3lkpS8BzwgoO2mrc23b2f9J/5Miu9rIXaHyMvzmVXjvM76F4z1QzIxBY9kjBu8zKVTPt1wDMwWKdcev8yHfenV3lngqMruEIfxVxIqx0R3oiDsmddQqrgL7xwjoXtb+tBClSj6WrZ2wxBYmKxAD0wep2M95A9ZB3iKGoAVrSEG1Nc70j1Eq7+vbWZ/b7DNOenN9APewgmi0FNOGtXirULOa6rvpLW5wcIDM49bzzXO7fdivE0dU3jWh4dFe/w1ZuWXZ3oP01LDcdu+PpfxL89G9e+QYmAcbgenhVcAz5SPzvvTvNPnWSSXFeNUXS1Yl46S85V9COEEVBT2cx4tTmicdm7MUZVc9UhpThrT9KISMWKrumsCw0HYKwZsd5RLIC8ZenMK6WOo723MLf/HzYM5vi7nLJYKqJnPKHg1dOxTPMNXznfmv2SYwTPmW+7rkh0om9dT/DV/Q3qvSD0mJNaQKpLUOqFW1zpnOXgXEmsVnfCi8JZrr9hSDO3dvHhvZ2bC6qwR+6zxFWVffk1vHmWtzDNKPxx+5VsICsMiXvkhA2SVmo+BbN3+nelx/XOMucwMXpTJPmFpriTmaN2xKejZilmRu5KAodjxVzNH/npV5tPyznZTTgVz9eJMkvMWjfZRBel5O6jYx8T4wNILK82RibIklsOM6/Zg02hv7o1Oa22iGiZXnZGRXZo1Eiv5XMrvQhraEpx7bmFrgWXP4e870WxLfyegPzN1PQcJaYVsyT6aXb1psHdsIhcvLATxm2fVO8gD8IQMOQZUXFn2wPy7LI0FAttfTrXFgrxu263fS4QS83c93RsAsluOq4MFnNoV7DPcdzxjeLaz3Sbm3FiH2OgPf7e8nQJexBGRQ9PNiPGbJ5M9Df+bBQGnP07ZYj2RYAKByZ7EFbXmN280Hc85aRG2sk0qyRc8QbvTLKF9I4CTFxmvp7pPvcmGrnjEJty4NKBh72+NpnZmbVCa4X9L/+dkEv3d+wB7Jr4fkrmyVvHnXeu1c8T2B6fpte647oa3qeacszEfTr1tNH/F5cRFT3O7BqUA6TyjdSw6ROVYxWrZWzLS8TvRx6gDT17zdcYQVjifopAPZki8qPKnCRq8twBFrJU6O264YUWhiyrwMH/PhS2WoOmeIj6UNjxvBnEcfSaLVwunTBhTORh4JZVJWyxUHJj45zg6gjPkjfIB2IayhUGw/qKeDdSeehZHvfM44RIPCVq4hN1X80eK/LvsJHCcFeavOwg6pyDE/uqKFSXWKoczsxtJdTBx4GWDD0r0n9k/8bK2l1Djde/MNeYZ+owa/X3nC9KSpDVRmuZgq/atOwWoLZX6YUmggAPbljRKiYiEZ7OHVtDrX8KNOo7mHiBBnbBAgPLZnDzpGWUk36UPTXGf8sx7bh+EM50t09qawGnTZ/sr+W09HPuWvpEPdk46ce81wxTFPKP3b8n/3TDSwl0W1T12vo0mmSeX1oURut+y3S4Pwv3pBeKISITRE2z2R/twfAyv5X5MFdcyR7MhYy7k4lfx2R4H1h55HHvtLoiY+8J/iHNmlQ87cPKln0JrV/g27V9RDdS3w9+B296pw0nllm+c8xWcuQnU7/2OO44cTAN9f9VPZfiITDbankiNJHzf8gW0dFtPbHZbAFVjxjn4XmBWllEC/HAVxS3J1A068mocLNXHzxHz1UvifwkLePhsFpzFcokMlzW/zfGeIx/GND7V1Vk/zbEjKCqAZzwTLsyFGiIEZ/PhoOq2XAAz8QvwLYjWaiDvQIEHloUyyTvbH8BCFotO0d4YA999XUN9dmPdG1lBdq2I+M33oBj2U/Y57Kmkf5yi3BTMsVsT9D+GIYFlJK92+n146jR1ZegrccjcHE3HZsdNr9eassaPlu67rADFDKJ7BwSsMOejOw/q/kbsdISs9McNULJNdwE+BPatIaGsOp5wskG7sa2aaDhUoWIxYV0pd1yLg6eOz5AR8ICPL2UOTFO1Y29/LgSWIxcZuljhtTCvWaG5q/nthrd9RB5YZ+4fetJtyBnKXmukDe9MOJlcgY4iGYCnMdPaE45XXRXCbKKea/AGpBp/IfqUmor0ZLRgHL9ZjFWFcAzLfYuI6jZgLTkXlv8SZBAF+TonOCXYCn8d+VL5amd40j3lYcDhc3taiv1vA5Au5+Z4Aitpspvm569o/2I1132Frk5vNxEaOM4ic3EnQD+Arfk2VAE7+piNvoKmXvmcJMtOPMHZVhVl5IjGXM2D+ctbIL6nKlB/GoCAyHETK5e3sCZ78foqNovB5lJmvrbEpFtBve/XGSQ6A9QVr960+Mmugn2b4U1K3N/dL8ypHMW03NkxbEeiPVrZgUVdB90fvbH2vBreeocczq53iyuNpW0NnooY4L0wxnqg+MLNSrW2qf7CDTj4cSHUD777WAhc3PJ5VFQ67JPGh1EFG2WZLqqOL+IyRRVzjGw0C/76BRePBBr7xM832tJe/s3k74PmywwwN/+9gQYTY+r38RF4r+SHNTmZ9+ow4IdbX7R6zTHpWxu8EHWcUPqeEXmekLbDkCbmYAzzEgUF4HR9Thzxnm9r+2c41recWSB5QTlZHwA53ECnNDi7v9YzB7ZEgtRJxEqOFrNpqjSurixP6rZEeQ3ZqP+qk4J+r248YM82mMolGDWpWmfUhx+BD7SZ1toWrOYT/P5vNZXYnbXO3d1nfn7d13JPY0TCR1vmV/JOd1olUZlrvo+Rv+CdnQIzSbp7foXoEyoRtbdCfd6/DmxXUod9/z0uxF52ViNpwwxT9nXQDylCJw3P8baq46pKYvRzOFMtYgxWw7TThh0f3wsJcq+OnL4J7q0V7raRHaRF1FbAP73Z6RegZRez6M3O9Mkn64K0EUQQc2ClpM3r//tOYuzmfIGOhPqmCuJYY6/e0F+vKjahXvWF8e5+DhRmxOuSJa4HJUX64PwZwD6uwptQvKBZhwG0FP/nXAvimTOrnTu+xthfOuK0U559kTyxpFrSXumxwER6Tf2I4u/W8DcnYMirPMcM052vZsQ8s9UTI6YkW3y4vx6oT0tWGHQZgR4sONNxkmqpLPYmmy4v4rI2HxlT1X4NFHgjxqEe+AfeF0X0kkNnWLWPR+txm/jdh/BDqDfHbdy8fivjz/8mbMRTny9fuMm0O7Xxrd2cIDehE3R73P7rHZDsXKdIK8c2U/q7go8ohV6I8wgRqYJUQqpp8K3+cFKMLfAt6GsdM4TyZ4g+BjZX+nPf3yw/uzx3enX7psA/OVgg7hyBXt7qENsmnKHqV3d7TecOOGva2BFeLtmkC4EzjOUOUPPP/YJhxI/84buusMBOaAzcE2JOHIyC1zyzKURhZhFToQNd2fOf4m4/iZc8oNvO8IyBdOTb0d1QWQyN60SukuPhGsReA7tYmOmkxRhEEj/icfEHWKllJcp8CgYu6MHX8ZPSZOTWP/Dm/vbFiN514Oq+kvSel/31zfKtFXOcwUlWm+XuZX5+p1L/GTmXNM9KEpCatKxDjzMdxBGdTfZRhOMSpMucet+w3cTcAjfGZp0Lkd2YwqrKpeOrrtFRR1DGj/lcZ8MZSyJqfxPCecFALCDzkKIegAVrg7bVn7OmrjczrwPC2SCt7ubRvLMR0RAdKwhjcrqa1fnqs9eN771jNeRFHbGV9vOUFiM6EakRqrVaxgjPyvcmuc181oU0PnDHSA17pjF+XIWEClx2qguOMt/68bhc07gY21SJgP22r812jQyp2wWwHMFilcd9h4IqDdjdJtFHFFbi76LzT+0318XfNKwS6YrdUNXHRaCD9YtjuHUez5TL8U/AMa9/23EpeBB5RlvR6nCTKDbdVJSKjURGh0XJPHA0MID92YjGIy8pXCVZKTZeBy8PfSzgee68f1XV3jxevlCkgJ774tkxX8Iz5M/SSecKgoFq4NCN1+fWRgFMq9h9gNH+sDpCu4mH58pi/zcrZPZGw6g/stjHdwf9kV5iKXCGeq+QP1rQb7AOtjo2RiOA16WZ2r4I9ZjvTVDP9eRscONY6sqm55Bz1xUCNJPU9n450vsDVMgygaNTe04mtSbgb2E2S7DlNvVDVP/nXRYybSIm/A8sQbMxT/soM94gtZZsOZI9wnv+KQdg8QRRyQbgDiVWBwiC6TYVJUAOtkvB3IrypbzUav5uM4Ho9OYI7K8RwZhlstamx/UTm+nxwVVUF3oDiBKx6VzC3Xww6dkEy6vJnHF+VfKKk92bt4nZRzK/RnaKEU+8v4Da8lchNrcDZ9hDpeL21vFGPSCm0Xgkevu7FOEnCymbJX/a3NukjLPOPWHtrGVcEBeTp5hx5TKSmZTDwsfe1uZWVNALuL84v2ygoSuEbGakyv0xf5HxCmHhkPu6gosFcPUkWy3AVchImQHwdgAIhh49VAync53frL/Z5crw2dobzWgwpsc8VFhwBAsC59P4i/0QViMIQOLrXQ/9YARdsbhRqDp4inO+ffi+0kfq7j+MgEQ8J7NcNyetyzzlfUg/96Z/kAfdLWDxr2LTqJJ1S9jV61SVMqvnEHXQNlwnoGFHmb+w9h+DJhnh2m9mk9uhKRpvqRtZuWMSE75jH3ynPr+OGDVspQhMKd/ZOJ6M0FmG+2xgB3LFpwZFyGNLmX8eOaX0FpMXdDT6o+7r+U8Pf0Ho7VqaMnICl97YzL2ci3hD3SeYqGhRmPZRgnWxPYM3+aP9ncLKNc7X7XW3zDKOrOP5437KjFIqIXZeTg6j/HqJ7Nv6K90Y0EKaxxnHiXpo5eIaG8/1J3dqDQPg7CsGFim8mLuWM0759PsaeNThlDNx0zWitZ1c93sWH81TXDn05WB8Dfpg5gnA2E9DvjB4MS1g6B0un/oHXoiYhPW6xQqmQ8GrCnk432PmbJ3w7gEiQW9AnkhcgEaAKYhN21wVL7iiP+L48SGeA6yChmd4+St+gM3Rpbb+3Q7+4cS/HD//5Y/qaDYkLUeNOWmYgH3RF2Fl3zAZYAsbS98EbRyjpkPcKuwCeewog0KcHENnjMPNPTynFZeQ/IXpkKUvZzSTbzKV63c6yEMjys5nzuknN6SB76ftSUk4YAKAB/OU223tbwceWJP6le/rlecIsTmq9g2QzKcf6/XYOhGqI3J7ruRewvkSegA6woz1/dxPut7eq/tWhmybXvYScI21tZUf/eaTb9K1+0ZByLAcKZ4GFGxpmlye/uF7sb/gIUqsOTUdS3EVSyISBsfndUxfOQ9NN8w11uj7yv7utN+jdWIJd8LvzLF1LETLRf+pJMPEGfkTmEB5QxUnRiui6JzDGijbFiMDxGUljJ57W+yFRg6fann5mDPmtEe6DThbcukHWepAGOkeNpPE768wCiP1VZrRrCpP+mQuGQIsqvDg102kulChXIoTLUvYL03wzztqNKbYzaxg7J+Gc43CHp0aD8y2a7GZTCevc7lxq402ZfjUGhHnsR9pFQfmTaxrpyabjZg/2GEeyMsUx34xTCqBbeObNj/Q+cyhfy/1LXFQQMIcfSmDR5n2HhsZRIfJNsEJFJMa+P/iWso1HHc8RTo69abEUhIXGiJ9hDsPEiUEck65JnNW/eIUF5K7fsRBNUWpcOaLf60GxjuOjXB2h79D/0KSfiazeWa886FjtzJf9YCR8DW3G2lW+GdChU+2fpDz4ZjTmWwp7A7GDRTG+fEvt+1dEHRqHLl9XRDrpI6rVU8Z8BuIHxNCj/wkI3Fs3AwiA5M7Jd/Ub9kcWZPtsUe/7xsLFvnMftsoRF/rEwbR9MOxD839jV5DDYOW5koJTmmAbWSNdBzPgN2Vvw2NFoZpDvnohngCfzIBleSMh79Vcq54BGMaUZlrMdi6CW2vqlY1maT7fRFNUTtdI3l/FfelK8Sr5iTDHT/Nhc0efurdSJtOzJgJ5dLNz96sp0cKPPz1BT/Mv0CijrkrNux21BvJNIVxn/oKalY2q52b3YhRuEIM8hhT2HNvBoZLGIvVH9cET8swNSMMghO6oj0Id5Da6ItDNwLmSLg1V3jkJuuplnXeEp0S6D2RETLxNu5AXy8SB4trkJ83NCWPOGIxENvMLGndYwjOFybMuo7t1WzOtPNFswYEreUUIW01ss9ah2/rFEROn284bbUuDpNd3vtNgfewNi3TbaKlxXh5VerfNUL+E12pdmefTc2KHXbj7C8ePCvsEmx78X5+dO7ajBbsLmlVdt3xuOnKb/tibEq5kGk6GU3Ij23gl58dlNQ2lwaKuBhy2qSQAyD+NheLuqLA1LNTr/UaLYaiMw4bIkndnaLOSWlrqgSpChdQuBejJYRsr5E0VIDxbH2YsIp4X4nD+FI2s6/naPt6GbUEaKQJmurUAcnld1KsOEFSp4++OjmtQSiEJaD5O3nG3+1mf8PgaLiGEizf14Cnnw8510efBlJUyYWrO47IBXIhkZfGsh5zGgh66/POa7eA955RC8HQXO5svbKuNXssMLojsvN6CC/OVhvOvNRbDbqW9RSXZI8F4+llOB3uJKnCIS8iG0YOVjFcD31q8Tv56vcX2ZDkeMQ8W4FtO5fMPLabd/OH2KJddl5psxAJ0GACHLZmraGNuTeig3hYTvNc/lDIsvbeYN3zxcQgwu6Te+vq995OIeGHhsG7jAt/+A82TsnYRkTE31UCosblcg7C8vrn8ZsIlXLJyvuc6seVHtcl3Ss1ZD4ulawAgkoXtG9hDEuJ+qqMu+wLbsxLCuSejmChTck0Bx2t8hs6gibITtgqjPJRW+aNOHFN9JzZoBYl/VV8T4oHtM0z50wJeqWJAuXY4wRwlH30fq4ZA0gBQuK4dHbEu56s+6E2bHWzdTlc0S8x88U8F7A7aysvNkdZef1YBnRRz2BXi7bv5en2jtSdp6KQ0sw6S7BPPikK+B0hxcl5DcNZVJlKQWtwuTCpOPtCsdvKX6iU1ZAUcKeOcK1abEfu7Z3yypOxqzEZXmqaTRRf1I7KKctx86Njl67sCB0IC0yL/cwReVcS9XdvVHhI8as03JUmV+91CrZNYQwedxPbPO/y2HrJX9D4atn+1fRfnXF/FUXvrjM7xGhVuzC7HNeymL8z9NuuOBdhEafTOVbK8qjAYh6luGtPhkY+Yh9GV/C+kXEMgqRNtGLSmuA5hlql1q29VnVk0MxrC9846f6NSQ/r5DtKt/DvCQKLvyaxPZFyjDJvZXZeCDZL9ok6Y1P3yN79IDGGlWdfK9KBRnVHg9c41sHwsVitsyWEPJk1aSVZZQBa37Hz9xAuIBQslkkMYGuE/RcgkvpNtu3mfDdF1p+S0geRU4hzI1EABR9FZN7m0zZiHROe7SXukiumJQ7ynT7IqmZg09tCai4hM0Y/bNTZtyB9bCepBzZA5omBxOpkyQ3Itloi9aEVmJP7sD1SrbbhLleiClor2Qwze47vnkF/AaUWVqatDAPQVqPsRF/FSz59qub57qrqalRKCqGwxtjDwNgBKKg1XA/ZkmCLpaOjlZPauj2b/H25+HnjZNXsxYqNKUtcFm5/mFPio9ZojPqIApFjhGKhqwQD50Mk5b7BO6ZV69FRSp558q+kxL1Th7w3mpMeqI3yMvTo+bv1gMXeIfZEN9mjC2clBULdmYmm2jySa3MxX1pY9TbgnITn8T+/gtMDTwkzSoZoDEk9xzrvkmLV8WfmeYQxO2v79CFr20+7fa2X30WvbeFQeEFJH9OAe258LUwmcmcnsyn4PalXegv+B0QXnxACLE9qa67U1swx92LvI+nKKcYnZNXeTgeYtkEuwSXbv7PpkFWpfHoaAx4bqKKuxY/bdYxTWcmIcvHQqHFVt/Jkf4fOI+6KD/Iw6+evur/PT7A4el07MkZx3GkxfkGdXC8ED5kPjZ5biKnhferMgehVTCQ+3BIFwy8KCK6Q2jjsyZ+Sq+cF5HqnltsTKDMxq5XM2mjzXLMxethc3Bt2DN1vgO4Y/YO3lmU87IKwIwJRjSPfQU5LIQpHK3E9OI87GRV8Y+1Q2MlTASGWzRkVVqhCaL6Fg919EZ1pBoikHwT94WNKTfTvJ9ow8BhyZqa/JaI6RP0CcJLNNkzlZP3pWvXTl/DL70r47he0tr6SyKdfX8GCDvCQ3FSxuC82n3fZBajgmRAe5FBCPPfOZUwMhNa4q/G4Hbacfn5XLHJqZYfGe2Ymk43FBDDXNZZT/FebrxkB0iJLHsPwEUdYhaBfBSPclSOluPKVixJV41j+Kh2PSAxWKOQ3054fzMGlpB5rPwi66eVrWoN2A4dSBqTyfLPagfQXbPXwScReYGvaeicc525nZFmFQRBH/Vrkmqr0cAUE6tyjC/Ut3WEY1Tc2RcBi7WpaaA4gJlZfGaoOuObScmymacxHU3ui7w2OrYVEyjT4WxLkOn4fCsxPDb2+YifukyC/9tUp3flniaAnafZpPoXjGBPlWQg7hQgm246FpE/0hOFlkuA4kEYhgsUOYO/jOKb8y2hbBrBOGCTLQUcQ9vwGHepamqMy6sT0TmoKFN0JZRdeZ2y9A+VJpRsozO3034cY4jX/WU2vSX6IsIlyjj2hlGpG4PYVONR8mTNgoYb0VyjX/SrIV7OZemncCJ80fBy8GaYvSc0UxXsmf8Ehs21Im29kHG6vGHlHop7BIzQm10J8j/z2zYOn4e4tn4qjk0VBQbxn6ca3kDF37MamNPjs+wwCezjtTyXfdQ0BJcu39s1eV8Kiq7UpUi2l6i7INUmq+AM9XeR9F5wVpVrVPvq8byk6r2b6KR0S1rfiPdOXOwHqHI2ps+EZNQ53w2ThHTNsFs1vrlG3dqXzsPa2EVdXLdVYgX6wSNjSo2luDpxhI/m7d/TqHF7qKmkWx0/HenUlovysfcMgCLB9vN7n5e4Ivk4dr/94ZNppPqFZZg3Lq6+VQmx6BZE1Aqc/zlubcdJ90okagEgpfcPdnRnB2bxI4nRCZK5hjo0NdDpFNIFNAO4Ft9IxHa7/eYUov66drimNllcmKAagaJ1jWP3/qPqKbVm1LdtfwgKCIu4OgdRwd+frH3PtzJvvFtbZ0s6OgDmH9D60S0//tykJ8DyipeOy4Th2bDfGjy0kmpf9Ua9V+bEaxyj5q1Cp5bk8ggR9aDzmySRMQo8wZus25kTgcqVKMGfClpH/sxv3F3aq3LYd5A7szlijQS2LrBPWSlslSvPYoZxy4KZ0JD8tK+vxL3dsDZfA6A3etX3tc4S3cZVmkCYgBdSji/xQDcfVw8Bn+N/Wi1dKiHs+8uX627VTZ3nki0WvqIF3GV1SDd0z8rqs3mMxJixZHp6B5WP23lu5uvnZUFiV0kHIdBXcnyPwfF4RLUTavN5i1R9iIfSCoqTde2gEjB3YXcg55/Xl1F+/TG+vEe6yn+LXCnUV957c62tc3fbb7hhlpt1Wzq/3VpcVdaId5u6VgsIkSnAmmGZg5ChznJLb6VWAuoRo4WGkYutwsNSDXX+kePkHLRbO2kBs4LqOcQ4c9bv4bKulTXaQDBasl0zzp2C7iMjwJ0FFaZEzvmqqckSSYvjXYSljGlSKKRo2IOzq+Wmm5Nt+00oAVYcX87LUyYYeyYo2KfdgNS7ntBFBb6ATrYsJrJxOCCtTWrDQj0VNHW0lLwHhgD2MU57KSQZ6Wbpz99gd5tZXu28d2GToozBaqxx1gEwiHlJh3KPBDQJ9DGWD8pGh3AnvBridZuowxEp5ok2eEHwk5XtiwniaKG4WOuU2nUWWutZ+FWeOITZCV619GFJU9C7UQ5Hu+1FM2XhFt75BAQrTHmDJBvmpzFX/EHWv+FxStbpbO9r8g6z0p0Y9GzduS9a/fsb3wHSU73ehLBCVmR7/yBwueMnuRNy5MfeOwDUwXAnndb5eduG4EGrbEO/akBjcoresNl4QH1kj/1PBjuMk5AZR/bc3RlkszUMDoa+PZ8ZOLXmIV+0gn0Zb4o9Y+I59+U+qgxkf5j5abZkVYKc07aenIlifM7LM8xJ7NrNfj3tuw7z+FI8BJJlGnXDK1VM50KJyyPCnxRH5kHG+hHN2fhrMXteRrtMS+ZDM4nvTFt7+nQizNIkvgtbXRULx1KUdp1ZyT6KGsdI8dmS3aezloz7GNk1tpUJmoQm3n9aZjU3o2Rro4gOy66w4HH7m8zTIjFm/6gfFli7KVIaCgtdu9qgwIBCn3rKwlIfCNoQRFyIlEa9GVaXZL+Mr/Rua5ShrJOBRmcnAMC/hR+nUT6sn2hh2i2eYenm14pxLPOHLNx36OwwpcIpV98vuiV87YVEZKWSK27uzG4kb2tPISqPOwbO6CD82r2dTtiAxQoYtbyhByDOIXIKnZNzDPsuBHx9hZkQ3XpIR7oFfQvPGdvQzZmDjUlkldRQKSntMnK8hqLlKL5HzKxNHV/JY9bSurGMQQz7CmVC0+uK4TOOaMHkI1sv3j+9KTw5RlyyEwm4WhhaY/aCBBj3OhurUbP8Qltlp5HjWXy7kAz9U7NdGpa7jntHK2jo1+l7BncL9rQeY4ON+VK/MmT1I1hZHCTCqbKbLCA2nkcJC0LOdx4+xiyR57OFg6mBxgrz4/reKMAHbCWeyHUNcD9YMZ9GF4Y8t503kfeiqvlLtSvmpvUL9PUNOADaFB1rxg/jfc7dMArGU4+hmwjqgs1iKfZf6W64CSoDvV6xkYUb6jRCiA0M2VEQ8bdqHdO2+AWvF1mN59v0wPVbtIrj7k/2B8LHqnOoKao4oxJJ7xvpM42Z9XgZCJMKLVxnnxL4WrJzYORx0LPDt54wjY+rb7ndHjpOLio7ccgBR9FnJx9ES5KcsMdZdA047W4B0BhBtpK87lkoQjYjmucK78Hq5HBIwWJdswyc0/5qLQFQIj1/WuHJDBtfvq1Y+FutE/iwwHumoEyu8WN5amxiQ1uU12GWSj+T4OSGxBhKchu6gWqZnkLawdHuDbVy4Yzhm/srSxh7a4zAFh6veKkeLr3s0iz/0yFDM2YQhm9TLwx8So6ErXRu9iNyk7b0ApRG/VejxurnAPUlpd4DLC6t53Lz1OOdQZ5qdTmCJaKKly9D9YkSDzCCOzUQ1jDbv8dYRLjr6LZRMR5uCv997Km1x1ZB6qTy/O6eQYd8g9z2AHD4Z+tspRzoFeGh3aBQY/3KEZ4YN4tMM7KvPsz7xurr42raGpBeDqdN8AgKcq5Fwj+3KrxHUK5GZeMvlh54y0BuJf2dwrRrdzC93w8lL5JcHc/RZfCTNAyU6JsvCIG41djlnmne7rKbP93d6uwnv2J9ufAjMoYa59reEevWGWgk9mS8J1DNYdkKM5Q8qT+GBXcAMTcIiiRUJnbC2/bPdEMgrFb1jQ4X8XhPwKA+DYZ8HQpGBQWaa8VIYFbKker9i5WJlJddJYVrI78dbFvkjOxGxmj+Wo4gBN495hrpGOXPjJ3xda8y912fJ8BNSLYjornBfVV/ooYR1HBPR6dm/TN9onbd8KWMAsawsj82a5tTH0g1JcF9ouE6L4n3GhKG79O47W6qxrPxicDNuru6evMTo1hUHGxKzLIgGUHfaJ6v9gu4RlqIPxNbX8xdD2JLx6oIo/jK27eFzzMjVCPIlnuGeH9qqnl3o4vSg5F6qdaTg70jkKKCHZ6NLYoYb/emoRgunt6/bnbNjdEfTW+UaL3G20y3mGgGq48CltU7GlEbCSi6WCG5tha7IbYghxh+spL1z3MSZ0SwKkqpNDKLw6JK41Kgpaws9aigk9KM5lgzIJ7NrhbrXL7PTdvz7LMd8Z6XcVq9WlJDnSd7tN05yPJ6a1T3Wi7veJKCOMTuQL23Vc77mr1P+7llXc20fHDAALwG7POxzDpTRN7FRL4LME8N6su8bghoPsB+Xn6sr0kWh2UtffOjyMnp3wn4uDY3ItXPGhYKK4Aa+9b2SKKaiD3qp8ix53e4iR0OILX3HMr9j93Q0gZaGkewp7JyXd4ZyxD+fcaM+niaAlG5IR5We7CyZnkh7CJosU2zei5aUceDwfwCy4BQa/fSGD/WKH+EcxSTWZejrZwpgQLJ2I+zvbwGheqya3GuuPk6CoU6KdP8Cd6Mq6tApixIZ07gduCpPatOoMJsGTHJ0u3qtGXV+1rWJ06mhLXvmYerlIhb00kRn5OLURYSCKkb9276mPH/pl/Fi2bCEY/uh5JXqbQybXhzzkjn8iVbDz0Yd3/wlpmTLEtgV3WvLM9GFHgorXHWTjzHWu1Ly8zJiVjOzeC4APKOo0s54Rz6TFxloZ6PpzcGKKsF+jCw0hOE1etYzsl/msDkkHYpNO5GfoRASJonI8/LpT7IpevxC/cvYcXtL45R6pW4S9RnHkJdwvmwEApskgEspNaEzftZnBKGuqNaMdHhvBNV1IYRpefM9Fq36JjJUVJNfUFYGlMyZ6evoURqOiOrS6zVx8ejBe56zHp4bATefupZL96Cknfd87p7b8u/OlEewJs7arYpLnRzDfxA6B8TLvGJlMjGNS15W/drKs23zl/DQk/dCEt9uqp3A1VyJNTLWpIS0aZpWX7B0wdUD8VIzDTQqMBvegYQHkzKfND+kk0u0D4n0JrWp4uO8lha76q41tIjnXzrK2Arc11i7fRi9EyBKEki6s9lH56m7wFaAHqnypFObtLaxax13EuZAbkdYgJE1Cz8avbTkrB/FagkXQRTAlPY57Qn2FWdzFCeuSPdOM71CjskKx4QfEnUR8LHhqWzO2Z5aBaPT+eGPF7mFr82rBdt2FbKRDEhoyPzceqiR5HD27ZwJx357kbs+D/hvSDU/IdfIfP7ineA/i0JY2iLnJzxME0KlqxTNEkTwrRC7Z96PgxOxKhdrrpX6YDahcb5mm+QuKz1MXsDplbVeZGD1DGKVq8yMQkQk6o83TtH827cLWp0C43C3NqUZ09vLMQG2PrPmrRzNVfaJ+BSZ6MB332WTXN4Ni1ZOKDMvRtIu1JamPEeS4JTYsp0I8ZtvQf74A1lz1AA5yKgJ125c7guYgq+2SlkFj2niWAJ+8oM3/ZO5STDT/suhdVewFcKdvSs5sdtGQ6uxdIme1MAmimzyyiVSi/yzXWe+I6wDwak1ZIPFlsgwnNlb8r/lyzrkUjw2mMKTrurK9uZ/3EsyQ/lLU5jSz/Y3Hzf/GBt9DF12DW2lJt1k23jEBKc7oePvdK/f4o2c2toUlSr81+iTDxjxTM0fno9KPLT2mHDIuDYSQhJOVW8z1R4ojx44O7yTxow/7eY9SHvactfiHXpq6b1/WbRrZsbx6dEs2999JWufBfeYt+EXxCPjoITAGFSjkHUzq70uqnaBr9lwZlRUry4mVuTbamjUV3ZcpSQF7GaY5M9AlE0oy9P0Av466yMSTIcWRHqWQrtVX0J5P7Yadsz5S2rzcf4NzdX7i39NX6HKcVuq6BETt60vKvG7qW7TleDQPq2hCANXY15AAge6oiGt12i8x+utklpTR9horRM2+6e7kotXcmBmMDVai42fAUNhtPwplry8+2fY+uV6/xXAt9/zq3ig8w2ktnIWePYJPFL4omuzUoNVb+fJTdf+tUofTL7j3yET9F/sUCt0ChdfdvPItRJyRk3YWXLYZqq78WaLQraQu2z9lVowmsNE2yO3mzohHsfc7C9XGjVR2/fY6HA54BxadNgkmw87STY1/o7oMPeTBBKmR+KrLyULcSxlh/bM4oC5MmP3kwUpwh+yBLUQ3JpLdTSMgiHRcsnpZfZwt9czaLi5r6LBGf+aREj3iBtkd6fBLFmkHz8/oHaMq0mhG7f+aMz3/Z7Z5XJBFsfLNIMcxiLPz8DeId3vyIki1L0p8cMbvoGh7SK899nIIEAKyGcAgQVtkDRHYHK1oz2Zqt38c47u6d6o/Ht+rlbNXYXUU6GArk3HXLGFhalj16e7RjMIUvroNQns1QIvy0+YWOMdbIiL82tnXvNL/+t4ElPGiFQxbgMmGvYY8XJ3vnIwdaN/0CxJ10f3X677WiQu1wmuYWo60K41nNDeybpEUTx0jdT8lSy26an3333wkfjowgvbeQav259UkAERvXAu/oGq91VU/7IzJH45TvisX1rjhk0Mq9fhizKkvR6DvX87r906PE//PzohmVL+Jn95b02lbqqvy4se0DOwasmfacpeVVCp2ME8qyUK4warvORnO/Uc2WHi3nFQMBrRbmj4r/Lx3F8Q5VLz9tOXv5nkSBeX8RoiOyCyL0umc+ASm7qziETR3p+XS5Ov06oUFkspxV0/URpPU2iI9MKJcFvFw3QJtOHkJSWIhg6zagH9ylfL3+u6FktCCCirBNhtiOcQWdnwzfeBFjVmaylFvG1Q0cX4vqD7iHrmvbQZ+jSxx8X5KJKV2ohNOiWbG58oT4joAS8oG+b/k7EEMfC18KJe2BzFsDxOeR2x8LeOWsj2kw8QJxSzkEHXq3xxvNpcakYxahtTbpgfdzl8T9DuJRg5JyGcFTJ3pm1SXRjxHDokFZSWyLBN9JAI9P6FTr741+l4kD1BuE/6CdLnNnXQTMc37PCiAA/fKSpkdFVNkBRs5+LEx0jnLbA1NH4N+H3ZFhkxpIQXEfX+g8DwEDxpgwbcNRUkEBicxA9ZS4A4X+ivsq1x9tYzwanJxGfiv07NOE49/7QoMkP/IyudhwcvpFTEsF90ufprpuOvklK4ZqasHjc30nueNmfkrYN3lt4Wcj68yGOUD/b8bAIubLxLXqYZ6LY01PZ6craHsTQ9jk6WlrO6r5nlm+ADMQORf4dD0kJIl9UWZK/gwvrfFqUNsrCA4u7gVSm/pGcQ7zH8SA5ovSLsZFp4x5Sl+wj1UZbEkaBCp51BCaEHCoi+UFajlsG0KeayVwdifGS4vLgigIwn+PIloqZrO7QzotT1fkfGS85ecyp7TCOM06vZtceWzHuCjH77MScW9s6ugr4vf52DFf7gQ2l9ljvnZcbkw3sd+T3uT65YNf2Q0VULKSnRO7Mcn8dzbLYKaXK8Q/Ybhzq6FH17fHrfkCM3LMHudkS0aGuGyUqJAgsprI1j7BfVabxIH737O00rjK0T9dl+hb9QBPztl8gaZzHzFXYVvvHPnooNO7mNFyPjpp3rvtMzcqlEch+Uxp46s+s4re9F0vKaGSSxb6vmI6ue7FRGZ6N88QCb4WT1gw51gbmMeumboWYeNmVCn/64nPQhYcswx/89Bi0hoSmwN04xIqiPAexsTHD6JX8s6kt7rJ6vAzGi5fhE4/aSTNrYqAh7gVf7quIFVcIIhhuvpQ6DlhdUW4oVFfktOw14UxMQd/RKXWUyiwg1vdbBYp3vSAWQmM9SNn++5ZcrsURF2bQYO2FBZJcB5JFmnRP8wmhFCYumVh7fP4qnvbwelF24L5nd65ngBEqHO7W/1/eK2T5eVr9I+K8K1cEh9b8a7cPwg/2BGQ4NrdVQxD1pjNhy7XA1IAY+PyhjIVebEYy/Y+YPlmIZJcrLzgtKJgO25E+CQ1kM5iJL8WVetPSercoUVg9RUQvUzewR1B1gXPVFh9h0lVjs4BR+UUYdkFyKIUI7MobDEnPvrfJfKGP/LIWOeM3ryWdD6er+7LGIiKDGFeXQGZTfLa5DqFxGFwh/Y9c9Vy5o09eIzHYA7q56fYZWN8xO50cpCOLTMPBloRBT3FlsVgbjrLBk2j6Ta9de6yXY7dquxkMnp44zPuNWdpWwDx1zE6rK6ra31mlzmCgBfI8F8gffBs3OBb++VJxZPu3IBjDS39zvhqvDcnG4GlGNfdU0s1ZGEW7P/cRWI+R2nfGiFGaeKkEYEaVwGzB78J74S/4tsCNwllvu22AGmLXE24Qc2DgvgnqB+2bOiqSyjDRvpIucscyp2z5PaRYBF6TzRzVXSPTz8vdRaTkWgdX5ZMNSghEKE8zYQ9m3D7ZRHhsYMneostSFN0KWk7naOAnywM3qS4bl2LbJ9AmSbOoHnl9vswstw1JuGgaz732ObfU9XZkg8iePoiGcr1ITm/Jd01XPSyL7mW5khaxKhZovbEpurXvzI9PNIOYtK/Ty1Rc52i4QJTsLIUXZZOtcBglye6vgpxNhvjJCqqfbc7hSWplsDVu7p+88rt7Cj0nSiV27ICsQ3rI4P0+O9WDmwwnVp8uGcU7aCUQTtkfMoOiS4OceU5wnwWRR/sAvs/bzkWyJvgijTICZKx0lnk0Z5+yG+2xoEwxH3VzK8tvw9StckMIpk7X4aj+ECwoXoy7DXyHar415uJH9sec6udnqqkIYcF61+fxeV32saP6jLyKVlrr4+nf7Z7CGV264HdJd+YuLqW13TgGl2XzGLOMXnyoeiwKQk2LFYbVe7IKXGPbROhUtX5is0Bugzz4f04vrv3x2XmrI3qn21Wt6L4/ZOfYk0M6yEeCuIaaOpPsuVuMiWCOEqHG0phAwEkrb0nPvHjnWlUS6VFtR9oBDZuinak0qdR9xw/3exJTHzoTGv7ffLnj51j31an7QWAXFOxUS7xVO5OtXo4+Br1h8e/kEHPsp7/FuhHFhaMDV7PUjRVdzAkqs8Ba0QkzDAwbbNKaQaWTzIKLXOzWPYZXAwVozJA2oyLrPakRSIqNdy1SkRydAH9mC4Ha+hyo31WFpKw8d6rb4IYrNu6iEZPKu4mZ4rDBYvKbCel0T/rK/EPEFg7k6lnrgYf4pGc2dpYfa2XenXlsPIkNTLHrcF5IQc6OPeoe8EizwpalX+Bsqdxzq9zyjOxUO3jjlrQlU2XKI30rooHpNSWOa4NbJ+TKrZqcsbAGIma+4+IhHXFGZzWIi4SVqlz1pnMPb1OfZmgfE4hihsLcmKtpSGHFCLDVFZLmG52UiDl4+V2+J/U9PLhgkFqtBgFfONjR7uFDDo6D3/y4q8bqvYtSSyit6MaSJKJlaiJk+8lBLgWeOXWSENBxTqQQn+gu41pct3iFhsQgb71/rk4d7s80EIgD2Yi8/MtE4CrbIwbSCfnnY6/nBl27wryJadejP9WXctA1K8YzV+uEzVaUcCMPoKZcffHFtEBbNM1+wL06xAmMJCxZMgdWHznJA8Knu9IVbMplNuJOlOayD+M7+NAt9cNOK9dadlpECKpoihy7zLagCcXARzydWjNivv+o1LNw8Iroq7/OsFVXKaghlNIB9LuV5KxU8DOrPrVv654tYdYZPnZYLAJemI2NuP9cDQwuXpvUwC8pVV7Ym9w7cctCv5Ra7RluFpjz9pgthfJnHHtnGjnPDWGbi1SMNij9xf2s922wZt4IUChNvRaQu+ZeDLeJCKoLNNz7kvoyKdaFuRMprVpqKbsubT4h6iWT39cMd9AGopvub8WSci0ZRDEU5nk3/eBAclj+PLLM6VXPuWNo0rdMTqyS9saQSiTLj9xKBkVDjZ0imQ7BcK7U+ckx/uBbrL5/l1thIq4BNNJpg+rJYefqLDx/ZdfCftSp/Veale4ZhE4YhKx5+qdVU2bWi2MRzTGvD0jmtOWolQbi+XMoFqNK3fgVXWd1p9Ig2JnAzCVo9XZaz2g6yDtnfjj+oMfmHYHL8Z7bUdRTJbDfcYrTrTn7VLyhW0uSnif3vnhg9qAHnFdHmAsy6X5lLdrLeCixo4PnTcdvIdjdc/kaaFtdNSaqJfsI4WJ0MZysBi61XN4QPTl9JRToFlRoSxP7KPRRjKmwjyBdvMCylMr4gGfdxUf74nKwkGqOsloJHR+UWmjMzcqGvGXIiUYroq7gZZb6h6rxbg9hz/ZK5SMGiJop/JtL3xrqQp68nUojqqH+/MlYBGTOryh4Wjr3osk4FHOZQSBEQkr6u5ODhL9iQzQfFaL2OCoRK3b3b7vsZGQbEvymsSJm8LMetI3dCHgvtKDWBPk+fXkFW/HZ+zybzer2+eBc3Vrm7eAFJehYPu8pn8ukXdlQtnyKwFdqHBavqePJXKt89YNrGN3TcrmB2nHx3K6djhMQZ9G0B8reOIsLg3yiVMKPH/ZJpQ7d9iT9bP/D1omqkeR25G9N3jQa7UpLWQ5BxisU2Gcoz94zS4EBM5Bx1R5iq3oJt8tGR/ZYbY34Q/8q3/PN45P48ZyIJVigxthotMjSB9PiOdgvJmJlCIAaRAnnUa0rA1OalJyrGPH/dAXQ0uOrWJXD1PKcDpsh20Mt9FH0VLPtvSNupgzNKBEHJaW+q9Cmx02jVaDskXvyWgeFxx5caFMUHc9NT7l92mVurrpimI6qYsWzr7hIPPkNBto70zMVEGOoUOVR0s1dSfHqv26Lr+IkgO4jaPBOkNG9wk2WHB9VOjvnG4hSEg3ohwtLy14FwYfqsm9vbEi0sPQnrRzJKAQPRtFUFN5pOw4GirzORpXGefvnpoZ/XT8kV7UkDeGYQjBBe6yqqTGdBEbvX+3nMqo2uv7+9HkKkkFx5cvivehU8p6ZxNNIfq0B6WW7MJF/aQ8M28+P/VsEembvcJQND23snKvztCAlkF8H5UQbPkg6dfnHCJFfdY7E8V+5eM4W8hlnsxG7Hf7lH7OJICsklrqrq1YqQQNBBZVMSSwemPrDT4K6GHj9YckSnVGu0zv0YKuXNFgDzCuYdiosf4yOSMA1Bzms9hZFMp5HEK8RXNdt2VlAiwdvMovceNbxQVE6voTzttaSqqJeD6SvQuROfayD6bVWvX/I3//U5jPFfbAxLJu/BR0pym+RJUBAmgmFvP1NQH1atppfaXyiARzYk+Jevb4E1ned97ex7oxByIZrP5TWKaaP6a/CwrpCpo5n0FWbFNhgc1I/vAlGD+FqFA6wVddoQzRTUz2PObvRg+GJqE3qQWsirFdrfdEa+GQ2OocFJUmCE9PMdpU7z+95Ofuz3y9HRJO3yQMLKrlg6CN1SEjISGhaMnqQ/2jRwdelmZFfioZhTrbVlx0hihBWhLvPD2CfD5L6z6en9xlFYGfu6ooaLarLn9/4xDtr0MvSlNXj34yXlj1RxmWD28a9VOAWwve1bfzHiZ+E53kDbwqb5yjfdTPANBVxw94PoElpE7BKO+i8QeCQwo5jyq4TyYJR2E9nav5YlFnch5lBeHePEf4GEAwro4GXMlXJkEoalCJDpWJFeQn/7ZfSV5c/FiIudjhv8KYFfF7VydPtR4GL6wMfo09IrPOe0ZQwjNtyO2sRiZjz1l67/Ut10Q+SKPowWW+EH46o25Cq/xftG20jGy/gneI83uEoFvWAfI21wB+aiLX/1UC9u1IGj1WK2mV3ZHTetMkrSsO0gjGJGrfAh0p/7ZOp/lqwmDOP9Mg+8D/vgF30EueYfaRhF46iD2056QWEPK9X4MtRZLX6RYwGq9sJPB7SgTjRWt9dOYvqLZZ9xiqqzBjOQv2r5vo9Cqg6IGhIusOMMsWBTOYkQdp5bqa38Qs5zm3XNRrxc6y8x8GPjarNOkIkesblaoeT0oFph9dMP9upzcTTQwVE4yrP8q4gLR9cz8/Uv/wYc8Uu2GVDtApjhzKj5X2NAIxuh5TaTaDAYmUk9/9pjg27gUz2pJHEalflpIF8f5rTW8bawm42B0x0Xiq0MYugo2DjOb6welCJ9dZJesQQIEKaw3X5vMJ0eQpYXu+XEP9HBS4jAxQpuIQKZvsgL6tez/KtXa3+Qf/ptGvvJhS17HV0M/XHikhKEv4Yq0nk6jD5b73R3SjczG7fvCiUH0Sskgvx0Wqq/ZPxUHItGoMehlIeaNPuqrPul+NTFWrqhzCNg9BZl7T32Sqq29vvomtPJVqQbZ8TYePJBixSh21GXUALqbx1O13K7EKCiaijB/FHi/YU0T9DZqgoiw6TOuX642m7ZqrRuLxhJYpg9RmookFwpTbpHTTuLzvLFA012I3yNj3Fqzg16w5EyUmN9hQjoE4rLawa4P4x//DaDjeT9yvOXKXBNuDkMzWSnzqydIprnvruwQXNWWWiuJ9Fn0Sd403/fa2fPfEo57Qx3h6CXeSyIxyn6ZS3LMsXVl7dCXmwBCbB/Xw5K6otwjFCF+Ke55DIVFy7LG2BnQY/5qwU8bnREAzgxnwZU12Ic7le90wBGiFCn/uPrz0jPL9uJTIHYsJi94BdgzcElvQeOg553q6mFVKx9OaKRssCjOqQklDpB0g89UaQKOo8+txxNH3YeH330re/rzdE4YcN009l1IOrbAhl/JZrJuQm778AW61l9DeIFv0tVquv3W8ZTy+DVMb6PbnOSkLz2j8KceFTJalFaCmshIWwYaEwNQ6GfajS9HC58SnAW3Skb3RamkcHmR3yRIq/jqbVVGPspyVT2xK6iAuKDqy4XCh/pdT+hjyPKUqpBGlLlyyZ0nq4Nn4OidqCzjya/DF4WiC4vUXTVI6KeVH2gPDVIPpNplXZlMxLkBXJHm3lBjWIZ7Pxf56cMeJW2f4hPhsPrGkoshaXKLCaBQ/LTmKtRlODipK8IvZPMPJL3PGaAfL7eEw4HjEMJuaulNv2NHyHsnT6CcLljbasMjImfndSlbAHTgGOgM2FmsvAqGdovE5h4mfTgmSrfUcye8U2r09Tx22kg7ycGUAf/J/DN+QKURnNl/zUa2kHRVry388sUQPWGA+SmbH8U5Skn2Wv7jzAlypJVxsfusiBRCWtWAakg5almUKKuAORqyeJjF+H8kPwIk0JG0lxEgqm4/dg6OhlwLJJtdGB8bniGgFat1atV6ouVUYz/EMf7DdJDR0Rhg5HKnvYdmm3LqIIsr+K0X/dTJBXeIGzH7wTOT68nygTkaL9CCt1jy5HViKWO9lE7gr7ifOBf+dlZ6fVozM4a6G2aXE2fzUuhXsyVvTw69p8KWYabul3/QOLGQUDMDqdQvJtNKoN+1W2C0yiv0BDo8hRFfpU9O1aUtk0DgHp+eaVdPkC7//I/oEwXeFThQaiLGu3gs1a+O9UaBhZ44IqaR2JQ0+Cw/kp699186fExfhHBVsjJLEEMk3o0+vOMK6/rS8PCttsuYtN0ePqHg79SCConvpJbkcjLRr8XVu4CQ8ntnPvOgPq8ehoRatlqR4aCocok1Ii86L78SXJfrMYyYKKc67jcePbchCbqgjk6IrqSBCokpZpitHP4VzvcHeVvgQ8qlRL2guTQmB2wMCzY12wk+2N81YLY149TbqRQf3q22AyGkUNHo9uKl5bwZmS94+isRNrPabWp4lESLPnzFb7fIt8qiHCCKnTR+etz0E/2FBg+slaQABKeHv9s/jdnrEL/vHJmtjmZY48xCp1Rk/iYEBmuf8USxGMGusiLkLSTcGdUOeZt8JyQDPyDX/35hyC6r5UAwY9W4pgw9IrfqbNWI6dj5uFGCTokn8imZJhlaznV/6B8rvW6hSq4OWY9wv5tdfdW56JFw7hjNv59c8EIevgT0pxb9i8RBGuyIen4aodmSxSQfYj9C19I2AwBFEXHY7qKBf0sgSFVXQqvsS4cmJwf5+9AtWy0aaNreeDpG81ZJ4P9ckvdrHjLdbYE3HGx+Hs4vLojv7rDvLrzL+QAma+Nx0zVysFjmsjwMh9vu1/y9tqzv3whRGOQGh+lKo8iBKsMI2iL/KUngXtZ/53c7tJwM5SZsvUiQd5i/jrZG/3ZFKykrWZlr/pRNOayoHvgvnGO24EVH4NFjXomsk6BLTUck6pEcEIJ2QpbCnXoW/IL18FwjLO6soqfZ6D3k61bpchOjlwcTKh50qtt5tfXX/sIqid7Rp1Z+uWY9LxkEqFXy1Go+vyRNBwcAPrlS5jNRPsYm4uRZA0WmM6W01Z7zwa2nK4HOpilYS1Rqup4FH0OKsj94H6eRRRDc1gKaloT/rYn6+VIWdVABhE8gfLRkTpCAfPhYS0rcS38XR+jqu0nMqlXlgWXLZQhqD4fjebs0gR5VbkxC+vw5v7bWgo9h37NcFz6kzHw1mRDheSMmpjCXiKTZ9R1wyBlaJykdcPPwrtmdUOuFUdjm1Rf9oWJeLY7I1fPXx8gTOnUIBvgAqAnZ/CZfofU0qUfsh0Vj/nT3p4hixbB5+uGvX8HZw7CGUsAFcWLjPGugssBE3Q1UYuT9pxnK7f4lqvQ+WhDWL58j+EB5N9pUL1RuJocACNGaSpvX78Zbfjl4GTfwG+47Ga2gX90k5wczA4PIoHyMIMxi/B5karHEKwvVYrulo4ZhDjNvidEAb3Az9eYGLWyswyNn6NlKNzZj892dX+ugecX/sy3CFA9LbsQCGo1+SgvCcL6qr4cK3kvoyxDFBK8L8/q+P4/yM/mMHZ9ATdrTQFfUV36k3g2zlkWCqZ2ZN0RGVJWpvnxEIkCmfpIUW+EhUwzeW1oLvdagQWU9vUaRit/L3dxXYaWkP2ge/zs3s93/Kplx38cqcGOD/r9W6JAqo+rbK6dFEc75P2LeIaqtdtBR8v2b4WUOgmq86WbKReL345k1kv6MFmMycovQ8/olV5Knfd0Ka31Sk7KaOlOb7B/AnxRS4YnaMauV4HiUKFENQoCKfZj8HII0/1yRx4yu40utSWdQsfMOPqvd99nLroB9tXdoQ0WcrrHQlO7F8dziKdRquDWrjyXVT/tjfIyC23ZvRuS9lfCypWh+P6vOeYxC/cwj+GQAZ/At39PlNrrL1xI7xWStPuyI33OKPM+VPPNSCIesAMQpZ87S5VemR8Mo0eNvtDHZt/7XPUrvQpxcFECqcNX1V7yYVW6PNYchlX8uMc2plvA9NrSKjtNaOYglo0f3N8ZWJRie7rFM8AfDADqkvV3Jv+eiwYVuHECNw/scMznDs7CEpmT9M0UJL5h66GpEFSeUMT1BZ3KO9JhuhuT/Yvt9L/CbF3J6BCPHbL5t4V7igI7gRMLHEZ4BuzYu0YDMhxxrHJ16EowQ9Kvlf2QgGGb/PmHTx9aUKud9TlgYwly+vd05H1TFNm+xuOs32OjzNeConBDUO5BVNsZSofOB8aZjxmFrx3HLJ/INMxuytFiVBF+PUWd52Lfrh2O+ECa/HovVaNK48etOBGj8/fPU4snUXoWFLvkY/0bQELjPAng3YqCO6wPVdxY3h1TaZALijhTSbZddyZBNFETFRBNrNG/WCKRbmhBkNxS0oAG4jzzqTmRXiVsPdKswkPHUAeeLQcUVGsTyNo71ZVkCsSvOAbqGEj40+GHWGPvxb2XVwCDQmfsNTChaRVoAy138u8RvyDavGUgjoVY/m6drgHq0ZiE8qRqDeU6qnT0b0pig+6TuKvFLW7xY5DJAFWEB+XXD7UcBoZk9JRFniId/ELi+mfs3vICY7O0BC6z9lCgrPpr2cOIrd5xWC+DHy22OYJ9uMDhScdro9S/7aJAw0AbEJCzsfgTf7Q9CK64NvwkIbOYiG9QZt/mfTmOvXWtldC/fOYYuflloLWBVgtpmamSIH+1KFrnGvHRfkA/8MPm9F9FMWvb3/UyR78IML+ENuX29XTmzj3OOCrWO7O5ha7wIqf8u9wf5JUdfF8DwEbVv6MrQOwSGW4C/Ery/5OJMmLoTwdeRDMaVu37tosjdcsv4lKcJEG7xdWBnIPYKddrpzDy2syvqiJykcwNAitlyL3c3V8zkpc1W1Fcnsw/7TCgVgo2Av+xbGekeJSX8fNaaOufZZhA2Tn5+e45OLfa/4Z2sVmpruzHTS+QxgXTyyvoSi/Ubcc2ztMOjknXtv51SuJr3TpGVQysDMYGvQEFD95u70PDhCRGS/v9HLmQUI/jyrYBKnXbTPYUs4c7XBPr8Z+UDSBFTC2qoSak7UPwultmrf6zpXRwAx6BETwZ0SM3gT/g1nQpGVu9ruPkELGUG1vTL1mRJvnfBIRoUuToUnS54qSK5UI2YxFF1L3NMCj4IrGAJKAKf/yytVWVyT/1zxkqU9zLC5upf5ECUMqSivZ1Bdp0IOkzgGYJlICTP/n/hkC6oOAT6ujUsUrmbOlYH8zQfwrQ6YEUP4aOP6MhLZJO65TQ665BXxEmdH+d1BER4XKLPpnqcwOcsu0hTggMvrmxjTI6kfflLKhJAq37J9fHIh+f43uB3iR+xoHWzS8naEF8otufkWdYwAgC1PhSSjbeFEM9YcxqgafFqqfZZrlwjd8t79Xg3sfjzu45DD7eyxebyKcbCv1f0vtx1ujSh1/vYtv3XwYAKJT8T1SKFv1MKvrKFPXXXgPYzwuisCM1XHJQi6/ARCX5RWtOKlb3mi6plKD528ovaXjZvxWi9v3dKBB061PpdaL+xxcMQ/i0CbFq50yCRkylkNUPcNHBk9AnFd7OduFFl0Y0D+EdpJa/mJ3STh7kEO81bA++zYRz1R+UsEo3HU4uupna7dr7CB8dgrdsscCdARKYHsoCho9+o8MTk2gzl8NV8PITMhVbDItBQuK4D6Lh9DO3MX/YR5L1UkPkPbMU0JOMaEuWnuJf0A5IPeAFA/hNR8YeR9GghWx5qeJeiqNdJkhi/fCUkvKSmExf/sOCzMqwNKPggb9GYB9xQh5u7lc2Bl5/EK/vVy9GNlrC+Lmzfc8/qZdmf7buf35q6FvybVfqicE3+Zl9v58qdxvo915QC67NLMZgH4VWyS7nmoNCfcEh8IfoYgnh/J8P4sJKbLdG09wKe12Hwn4e8DqZqcgfU+xADr7SkKa0C099ceH7R2v5PdNrDXgM+tNc8KNSLe021Gno5WRq7oZtr/6K1R/HfO7FXECKtcTz31/Wyy5+zyBapm7EmEznvNtpnP0XJKI4RWa506AN/Ie+NhhtnJetN37qCnsNHeyc/1hCaL/LkepgiNds0TSMrf/1HPduFsBCtBQLA7KulVkeb++3bKPInWOrJ1B8NGBVIO9iQMjENdXl787YSjaVf9Hgvx+LVeKrWRPzaxB/05pYH1+LvgRhtlnvD7dvHNi4Xuo4VIC6qU1JfG8Fxv7rnm5IoITwfdfjG4oIcO2CjYNkaFoZxmYlG1OQoZ3rQekXonkyB0mQzHMfRR85+jP9E+m/H8sfIAof1sxMDhgMczUb3/yKLBl9MRMGIEqXS81+wI4p3ta/6QFGdNL6HyDFGvFFO6VBj6tZCdlo/e97lqsR8pYi2Uz814KIFh2gNVq/FWAHxUMi51E33/QFidVOcugFykYpUONG78ACXVPRoZ8SLU/sf6/wxVIiL7c0iibBCtgKhTlZgcx6/Soa40E9dvbqv9o5PvqDEH8LfcGh5H+rsNRvfK5hoj2VQCx4jcEvaEr/60TNZvAM7JXcOzZIQweqTVY4Z+BbIVGndtbunMF5RDIQ52U9l2CgJq9MX2QUXqMBPQWECjfEq9Hd/udzHUgqRWksRWpOrsLMuqI4sSwuMB3RTcIuywB08gs1DY7JjvMBvfoRw1bNPuEBN4fsoVL2ewb+i9H+Vx5rja05heW1BwqZufuh/Pb5iy0Ba6E/OIs0QCRUjypdSBQFsuVH0bnPQo+QBo6vsG2YvLkcodZALgHQsD8qBuBw5Z3f6vN6h96z0I4gqHP14EUjGBnVJbFhh8n74VpwEoZlk/tUjNkTWbhOlBvfKrL1n0cEamMz1O/lgd0RDhsqoqkfDGZHO5HpVJ2+1fRXWbhTxNkc1PRpaX8EJt3nP1AzxRYHdLnNDOY8+LikWnP4v+JfUozv1vn/TNORd9yFF1exAuvuyfJ336TpUaHbIKeX8P6egkUa9Fz0zpfVzk587BRMtNpAbPrfWTIM51Pl+fKee+yOJcA5gIoiWusmQYmWFxMDkscBX9nX1DMzxpGGbiEGX/96MTE/ENRBSP5DrUYF/UdafR5jVIYDJdQ8fRJphoHsrmaqoXF2WyeY8sSYtIxabf9oXm5ojyyFylBNKmI+GJCJKgTz4l5VMO/ly05RBf/X4VIhTZdlsr9mEQg76O6hdeEFbmYGHYckl6NU4YZUMTLSdl+VY5koZK2o9xtnhAxFPqBX1f8BIT6ZWbiXLOjX/8dOlVQTdpIiS3/WRYJ1E9Q7AJ/Nj+fk+dYuHPRZPsF6trlm7JFNjyhnw3Y20f5jXb8f5PjX/xmasmjr1AxzcTy4FBjSznp08MzrYJhfHhxTQ4x3ShcrEAaoRFlE13tQkWc8fwuaLoDA8e9q6dVhUoDENeIocS0SL+fGzJf3P5LM0Eh0fD221mKmVjJGPvYyLZLiO5nbg4Mq3Q0oi5Fcw8sIkwNaPDj44keJ5jzo1eAWmGZI/FywVgj89P+UTkq5nkbKtJfqrg2nI8RRGMWZjQQ3Ww2FX7zOvJm/mUTfaLC/ljExp+s2XUdokPlgxXUUcvBCG+Nc4b+n/VX3K3uWNrbfdGx/AtJ9gaEN2LkgAI6mHUfBv4HEU5/Wx41BSF7f/QggQqweiemATa2Tb1n/5R2+0K/Ut0XCw4AAFSEAbHntR0tuKnsIOC85Fn1pz7mpJw5m/H214Fq+SIOFHsTlyRJv9Gx7/yPIBqWVwOJm53KEGfq3+EQiYVCErClD92NvMWfcqy85Rq4qU7dFhXDhb3ZUaC66kINewKCX6IjE/xvz+fsB2YyyWj//gGADjg+bzc0ktPVscpj94jmUpIiSYvkp82Jcy+9nusD3GG7JX+EIm+uPonXiFtza3Nf/c2nAU5Rc4n8uQ93UvI8NXUQeUnMJcLRtjgXBNbrhsQ/HZz5EHiTLE8l81QnmiuYLxOxycvH4Ms1JkOyeICrmVyWGYXFuPfkVU7//sx7s5AbYXNCWbHsiarfjUhANDDBcfHXb8el3gtbxgQylvxBa7gekQ2qG1DUNcvmTKgGSZA9Y9l9e2WGclNH1NbTr29WvaYj/AWb67+Hc9+YfyIfS5PkQPHuGEtiHBl6OAR606fPBfAyR/iyoo0OHEnU86vx/ml1yLURhvBrKlX/XjBkWhwoOliZq9NsQOw+SQXCjqajofsPX1Jhjjv48n9jKXJaDv5GDZ2T9jEINSdI/Be16DfTq/Ofx/dw+uZ5vijNDAbz/3MqdAecko2u05NZmpwLASMCAaxxVZqSiQ2kgG3ecZ4bbAl8IKqteH5MPf1/H/aj/yA4la6Xcvk6ufxaOviZOvp9EUtqAhbBXCvMTxbyc/KmSCZStEAuMMNnjyeAr6zgLY6NYaP+j1atscdIuhLQVHEmB2egf4TSC+MWsgjbZnGGn7H1i3sOo2iBQF5GLy624wETzr+AUJhsYoAyM1i9tQFr36f4LswiJDLFqdHwUOZAx8CrwMA0HeLs9Md/TyKyyKcSr0X4HGcthAobv00N/sxiHvzJ/mGRADr8DUuK1bcMcfMIHh6j/k5bTHI1YRzVx1J24Qt+3INKrWgq0eAkqFOForqeUpTy0HCDD2u4FmkAKYXDR1FWvPyobpKkn5mFwTA0w6D9goFTK+iw01n7N5LfB0TSwVg3duhM6dzk2ZxgYcRl8crHlEJzq9Lfai5/9aibB5vm4CUvPxsxd3sfzg0z+PyfCLZLH8pd435UerM3R7uZr+rX3xF8K1AABN9jX9ww86u7US/XLHrwzrRX633KH+dutX6Now53wDii/ApAwtjky/v+xh3K4GBXRHLZDwlERwLZwxBREtiN8y5Yh4+CrLuJuaZz7kpTrNEDEbvC+S/CpofMugHeBHF38OfTOw2u+Xf+xrwMF04ZiT0abFWpDTujfrhRgqs8+2cWIdXiHcZEjUxUUfvTdMjjfZilLEhgn/JuSfQFNBSyXXA7A2enoUNUf//FGkYJ7/DPw1n8Q7yGtrEZFqwsiYK1+/G0FAwSokR9ANuBvK+2+OwUhSwONGjSREdLgKiPeb4FhBXhdnghOG18sSaLj+F/SyS9SysqVZ0GVb60ljjJFcqPf8EXpxmamftGZns20bha/GsBUEhGc0oF3aAXM/4hO0nXDwcx2sP9AXvhfCBXmFGoOmzi37/6pk211+Yfksg+YzHJxSIpmGm90elOuPKPjZ/Uxd8kiZgD+hJpcOlJkf8W4hUS7mQ1oB7iFo03/w+jQ8bUDEyHGR+hn5Wgu7HBlO1fpg4z+TaTeS0WWE1R+4R5dvmBngFhL/sxmn370qugislLR+IfKeCG/iMnM/2MfT7aVe4Z3o3Xv+ewDgh+g+7Gp4VPH2O51a6RaWaVG95cmnCw0nj/i+EMUzwgktAF5PD7DArPPcEcd/3PaIN/Mj8xDs6my3Pz2N0ycoklAUaxhX5JbjJhnkStAj0+ZBcVMcIRgFgssv0tRJgik8MtfF1sN/WPj3NEMH2CzrLq50yrPHykKbuilc/8ff6U9+n1PLcA7DB5Gw9wa3MgIABsF07UE1tLMg9C/vxtUXBHS+vqcejG/sXuvmR/vbBiiZrxypUlf0P+vZYF9U/+PvTfrVhXJFoV/TT3ePWhtHulUVEBUVHy5Q1FRsO8Qfv0XIXOGoGtXZp2qVSvPd3NX5XBNQQhi9i3Lod+pq8J9Os45YbyQbr1pRHlaa+8lGka6XCeNuClNrz2xo2v+7jLpCqeVFNyjVQ0CQcOmI1QvaXA+1Spcq9dm92g3TVXsSaNnTfTisVj1nzGre80SpEl1bImBG6WDm9rXtvJC3ZkPaqBU9PuiItSedk0qDBPzoWX1lS9VJhct6p2qrqLotUa7bzS87VF+uUCKcjYnmlFbCE/TtLmoT+uzzaA575rmabNeHZ0s7Nbrprbu2j3hPj/xp8hcX0atNeU9Y7AMFNpRrISbY9c5zxohk3kiUdHZbNeXpWXrRM1uSjA8NXWvd0vZPYJLR2/655tsKqFvKNNpNKXRPJXmwIYr/zysCWMhbOq7o7/qOl33WtsydX5JXFoLMDa2913fGFrJUhzk1kK4a9AB0wt9PTE2mvXY9LzlJZAqyyyi0RKPj2szXeJVjt6r1424uK4MMXZKPKPNcx6k4W4qMm05vErX+y5tDUcHua7y93Ek6tOH7BlaI4lH/Vk1rMzkni5Vl/5g36ZEGvaXXJfvHltSY6gvre560KqW+IGG9SXNPndMo93tX3vx5d6dpCZICVXc0LzsrKl29DBbjqgXQMcbV7bB2CR7wy22Gs9VW61HHNMMM40jqYrMrfhnYGi1Zlu0rvWS5qw7GD0nM8yqm2rtWLGtukxZcKE3m5Wd11TE1jkcrreuZ957u94jtaUkkx1nf5XmJ6+55caHRVh1m3P3EleKtuwwpGLdam2MYNjR+pPLlLoz6+pi3Kt169unbvDE1Jx32nHk79f209Wnz97ajR5+eFMXguN3bnytv64koyV/rhqBeOBtVZ6f/NZUnXqz1W7TrffQG+l2B6a6VT3pfFEaj97kMq/z89WjLm6Wz34vtTPdOzPHXS+a08NZX+vBLV5F5mCzGTX3d5+yvtqYVlY9i7aI19J9pzoSqdXlV4xtR77QeOHEePkTcThVGoq59FK1uuxSs027PscX2Ydpw97Mtn79cuxV+IOmOn6/162f1bi6zaLKchRVn/0nx3392jrQ5iutZ9vbRB7ero0HbOA0nBMesIaWlzbWWfCs2FAG4fVJx7X1Nt3M6rX2qN4XAnXiXhXfHDXTaTTU/CH1YfbXZ/abWg7Dp/6Qaufmsbv3m/ryOoqNuSY20ur4FAJdKyPD3zQlLmpwm03M7xt3eSJOFlW9cnC9rq4kt9mly90ulPiuobq+bpRKrz2d3+hUeWuSCRundm5V1Nl9ZS7XptS87I9FyXhp0uRBTznGjtM9zge7dLbNHDWkuT/FvUqHXtaOjs6hftYfqj/facfDYncfn/Tk2jscaVRl7NrRgQ8ezth/eXZntaYa3eHl0l6IGg2YzVLHps7+oa0Gblbhlxt+u+5fjtb0UfeG/jXaTPWYpx7W4tJermmJ5mO0HIvH89CvTS9+5M8HgwZVV6tHXPGf3nrDH4em59xM6SDEi61eVZKqt5JW1ZNajyyRCy4tI+Ba+lg/3i61cyToQW8en0zblXd9vereh4OOunero+rMjhvzetRY6o/xoaUtRpK9HGBmvnFo1zrz3k6sCd580F773CVNDitpVz2uFlblphza8iKoJ/2LbXTX5nZ990NR91Zm8zmcomHLJrUJ9NXkdhl6t8paYd5/fzBS4plNX1KuKn1uupCO97GgPaMu1pxTTxviXraakeJ2q8T/dppmVqko6YxQnnt98kW9b5/908A1Lpd+vKM0eAqu+jm9gAWojoivGDw61jHmaE5mLB4m8wr1oFK9cp+ep7YbxeMBHdd26573tmOr4XrfqIX9fnZU7ud+mlRWwm3Rnfbr1/MhWo6mi5nxjAxcTn06SToPFY+UTjBSNq3xaGaO+Xi959NHprRaTWqT3FeHIdX7Ohcfblulq9cSxW0fOUtrVw+1i81rzxeZ6bKura4KP6io6XxyVnYtt5Fk44OLvDA3dpoS65od8jRrQN/yLjziS5sy4bli25P2LPRr7WTdJHqB8Hr2TLzOqpNLrKlTf16X107tRLghrlHZfCP+mh8f1VvzxKmv3MnGMO8GDeOoN/98Txo3yROrrctzMK/DVX15e1601c2a+sgtQbjwkt7ZVAfG/US3pf+o8sdV0nGl2ZDXr61ZbzGXw+5Ri7Pdeuzp65fn3w1bQvPguv1NMuvTiOBiwtm3ynV10KqPSXB1mnov9GLaitFYr6yu2Tqqu4NudafVsUesdGs/XXeFyqH+EFppc3BbKY/2+LCvqRdlffEXLAq5VmIljgmPzEdCnyjgVXMxuJzvpvCMwYrD9cUNz9RCJ1jpXA8R9eha5vhMONGfDbODwXfrZuWZ0uFn/KHtxKhN3PCm9LXpRbQOpuZEzYc2IZpdWPY1e9tQnOO04aiJLfdMvZlqyUonNkuS1e47b1g581GvQ2OH0Zq+kLbxjGB3NLV9vezaybidmO2xj356w2+F7XE73Mh+aPD6rro9UZwnBi1MGhPqskWd87zN3WzHp13kRB29Zhw7crY4BkpTozGC2tZei1zPnx2PE+UeDoW2k032le60V201H+fq6vlqovF8RYfUNdSet3vmiNZKTYmSY1wjNOdeZlWp06zv51y6a1DCnl0Th85CaojSMjrHbUNZZ8RPtbjOftyp9MZO0F4TJ0mmb9FOyC6kD6PCDApH22m2OvQHk+sgPFCjcXj3s+fbEsY3f7w5bflONk4aUrO38xfRPOwY42CvthLeWi5a2a1WbREptN1vnxHHcbXeeSTieGL6w3Ewtx0j0R/LkN+EkC1Tw9FmGg7GzWd2Q9k1nMrlTo2PRRL24jgmOpSLh277NGos5+u9E+zcXijsrMS/2FlyuJ9kbXC/2g953hybSbXTuy+4hz9Ytjs1UZnODrlOsVo1Y9l1euuxY/Zl2+xHtvlotMfueb1YqURLUqc5nN/iepr2g2tyj6bTZlBJNHXsj+iMgV2rc1r4wiolzNM81FaPtCU8u0AIbyy3yXix7OzRt3IU4m/6Hcfch1dFEGLjdt2ph02j3znO59pYnE+X61Zcl6OrsjPTa1cyps+Xtl0Oi2zWr+7qVJ/0hO5WHveqq97CPHmTS6c2OcoEsXn0aespJp3lcKpvr1XVXUr7+Xo+e+zmghEtHje5rzYfA7vtGR6nzLxK7+ZK7v2yC0LXtPcHedbl+HptHtW5w2As2LrpXWypZ00P1rGvy+3H8tw4CU8E2YSYV/7kYiSu7i2vyVAJou0mM4kSmxtabTI5t6btHg1C7C593bsEk+yyvRE5Z+yzasVYnuob6yLw1U3jJI5tT7hfD/rjeKgvr4dlNuZUjpedYyYOrq3oGS9tam2N+CPacjDgtdphvN2td41DdhnYTlvUvJhGQod+agw8uxnZ9V1l7V7cnbanpktbblurxfEZ9apKlQXtqE301nkajJ2uYkkuX53X2l0xtdAaiFVtvXs8q1FGd2lp323x4kmTx/VxpXPR+GPqb3ZLIkpV9dFNhHarOt9u5CBsqSepOST+jHVu80oUjr3WZpNdn4aCHw0Ho+Zgd7PVc7UebfZCuB5O5/2edWjPLuua6mw5GjfYtiPlrEl9baSP4oHfEga75twzolaNj+ybszPGkvesGnpw81t09KbTm9cie+txl+7F2W4kxWnNu3t115idFq58vfi9mdL2iRkknZ1DsxHFbuKdrLmpC4cLZ/AD6Kgxk5bZ91s7pek3uF7rNmsG4XZA3/aVzY5JR6f6I6xa/QqxAVva2Iy1cKm2eWN40hvRYRkmnYo7n7tEH1jmtB3F04fgd2bqQW7tqi+bPjy5YezT12/c7uOlFaWJd6wolQnXvnbaPb3z6HW7bfPcffQa3Ue3aV7aydCjZn4ztXfcUTkMh8alHfvxqbtvDkJlNOzSFFfzcPWvnSk1h59TcaN7YJuyu7+66sOb38VZkA5ONNnUUqdbio1l/2kvDW7rmn4Xg/S6dRSnvo7cmL5vkSpT2amuR0vryumd9dFtCfpUT+nwIGemhxrXVI/OdZT0ngLiNlpMN6G+fs4zdgY0zMiLUcMRwkCanevGfUK0hD8e88/KVFoBUTsJ63jmtdRQvMxo11/Lmpwb8vS5S3bFUxRLFW/riJuHDdVKNK+zVHR/5rk1ae9Md8l8NjY8olw7cnzd8Wu/bmQqzZU0gkqoqXc6NXGicPGdM7NRdbRcdy+VjMYLzttr+3Bt3sfrnRZVvNZa6RjUZxL3p2Vt+LQopgTlyrYR++f90Nocdo2KSG3JSazcxkHnwtdoydnwOLsoQ2PYTZXxht44COnr6qTtNKi6lmJIWaOrb3rVjFZOJH79LliqshvPK3WhE3nbtN+mcXz7SK3Ci6aunU382KRVefoM4imBa0/qo8ryqU2b/rA/8tsDwyCmIe3I2cwPN6p/JvexzymLkOt2VPfWnqnd/kBN+56VdfTdYyY7i21v6lfc3uY2HC+H5vEyq1SFdOpWDtl2faxWY+UwWjS4yl1ridvx6qQblmR165FvPTjH6m8eqtdtPNp6czdd8ZPjyhrT3g/aJRwvrq4ZX0xjUQ3Mh+8SC6Lft54un5w68ix7xPZOFzyXPH1oGe2wPnamVugdLLn6CJqDITdunZMx7Yx/+IHV7SnEHBi3rg9/ai06hj4wgpMz6K6GtXPo9bYtfsd1XnW9s3CfyHH1HnJuvJm4m7XgXujf05HU3nnucjOOmv31RLaDgJb7THdTtXfLlnaF0/fDWi9sbI1uNTqd1MSILE2qxc9xuRuBGz/caWcabpxKGC0r4WZM46nTbdwcxk6yqLT6pyxsPd/W2s1O6tF1h9VWo59yeq3+XNp8HSjb+Ux3W31PzYiqbJ4mspZZnjfvc4nXbfaU1B0Kxla/bIxTq95ohH7a0bWdv9w1d2agH9eJF4xFiXoGysEVD9bcUrRg7jiiornRRGwQX0VaN2kXrqmpnfox86s1YX3nz0q2fmz4ReLoxq7pd6LbYx84J/t8XlKLJ4uv6+P2EEsdy66Hh5V/MTeEnrR5OL8799C5C8+EbWMiVVZRVN8dTkSOC+6qJqXh/Tbjkqg9tk6puuqbg/Ze8QKZo32t3XYcxlNrGB6O0dKNduPrlez38dFa1rW2G/a0naxo2e4mJxxxD3ah39Anw1r7nq7X9Uri85IrnpUe9eV8ZyNzFxotb1hac9ByxlddalL17yX7pn9aX/0knTY6m+F04Zs97kzTTgdjl6h3oapydbEmZvshX9WepaXPsMmNGvjy4vnXopdZShb7MhH/3HoSxpHnXqauoaTJoD2zjxVd3wgDYg1tn+N+nFG061+c7kQhAkKTlShbJKss9SubnnTMFtKMn8eixDWbZoVG/KlIeCwCPel1DTqmtzEVvO50vTlcxsrOqznLaGSkm3bEWytNbm/mt2Or0tSEdHTfetSxVIjX54+b9rrTTI+rWOo9LLP96PQsTV/Xw+N6q3Wmq/7DHKv0zX8NU510x10/dNznaNaeaLWJ+jXV9CELTVfyO7bT7K4Fw5x2iGSzN0flputXq0ZjyM/tsfTtgtCIrhihwzfixz0hJqxGk3MNwWokcqd1XtQe9douCX2/pV1oraN6cS47szJX1bNFEL4fRONxRZTqvWM68tbnKJneDu1n19+hpZybQ4XGmquC4ctWP/Gj7iY0Hw/tEVUiXh2tQ8vilJ122SjS1BR1U91u4iSpDIKtyweji9LQwoSae3beM2+I3GrdMReNen15FreieqKuuufRDqtZ97qvXSq122rZuiTaerk8NLj+VZAoV1e7seLXNw/fN2eqJI9oXU1T9y+1OrcT3KN/mm9SU1d4NdlLwjYJun7b20yHo+pgsdweqD2SGRNFnE5XM53fr0Lb8jbtdNzohc7q4YeWsg6XvlJXA0nYuQ0tiZLeRIvXs6nVuk2eHY6DrlGrT6u+KTTN/oKrdNe1ZzlSI011/zEXzduBo28BVAbeyOl3ZM03zX+I+j9EdXXYX7XD9nCmoCByz3//oBMHCIlx9+X5unw8Dwl8/tVxdl7ur/lX/+cyErbSturGE2nejO+P6jD5P8/zROMforZ7NJeH3fJ6Ju4TB1eRZeEXJ+eXSvOvavVqDiebxXWdf1et5V+tl5twfS1/N7vkcMgu/pQrz1uSP3YPbbnd4gqefwsc0Z/P3xjuvNkNTudOUptPLjW3MyVPQZ2J59POtrdlfp5Oh3Bz+uw6m89oOLGyJYtQ52fyV0j/6i+P200wy393uaZb+N1lPTvSP4N0u9kvCBmRc5P15rocHGcBPZCcZ0fy3fq6I0vUeXrRw42cuejO2RezIA7P9FvndiWXWcL3l032/FOmaNtstwW0NZ7/6DnX8yFeFo5Unv/Ike1svtyq7NJ4yv5Ar/9t6OalqvSrjO5KnftEt/yJbjztP49u/gNty0W4HAB4OF/Xh/Cwn22N17fqOccSOU6543VO93A4An6i5fWaDnIkzW7XQxnNZBfP6YT+/peMoF88pj/g4jmUAkQZFC7KS58MW8D87HxVzudD8sLq87vGhm4PXHmBZwTb2eWyCfIv4RS2lN7yTFyv6/IMv8v3i27S/4A2yEYfbudg+c8wAugnyw2X139yosh9TW3n5XZ23dzLy/uKcOCnvcOG1iYjldY58ZcocOyfUCJYuVL/JVbqr3/ly+eLhiu+kSZb4r9BrcKPUitfpNUC6f6GWktU+KKxIiHyZUIEUn1RIffXp0LhW6iwJnK/KvWyrBTlX/W6/N8lOPFnCO4PRdNfBv88J/6bBPDv6S/pZ/XXvyYRvmTl/5SyemyuBZVKIL9w5LUgCqQF4C8rWYR/V7/9e4Qlf9jBaPlejrM92r6f1jB+Q+PopRPfyPRIZdZz1bJK/k8eVsv/k8lRjX7zi67h48uvvqt+fsl/nkY++K/u8P7lV99VP7/kP0+jEK66/OVX31XlzxW//5r/4tf8269l6rsdcvdAO+z3y+BasBe/cujC82yxWZZtx2rN4KTCMX1zJhfaHPZPrjxT6/zd09A5WeOrX3kaq+c/cmQxu6yZpKEuBXGUtl3qfvQOlw1cfn64Xg+7wgnKdhPSA1cqj9QZQAFZFWXToiQqWsQCwkBk9JazyzHfjtXmQdeholO2e5AHPa5/zZKL9Ou8zNnWDOh6VALmf5XP2s5288XsGx2kmlAtG5t16Rd6Qz/mD1f+1yuYNy6QpKqgyf85xfPdCqMm/lmFUf8WU1So135x3O8coopYKV8wf57vs0irX+il/z6BfkU7Lwuk/oyyoA3C/ZJk6a9nh9T/LFnVvoWsRKH+FgySa/KvP+fefFxM5qVfcvXDLX/R6C+pLv3Oaf9uiq19UKzSM8kXzdl1mczSD/L9UpV/tyL9Qi+Kv2bHzf8NYZWf2r+uVCX+C2sC2OFD9X6X2pRl/tcbxuVa5VdV+tSclV9fBRfF79Kd9T+WVR+7VMBJ2UTWv8LgZjejAuLDDoLv9c2ONqdtN7R8ancJZrQatnHbPw27y6/LPfxGvIii8B7u5QWGqQJaZO4TKVLlm5Aifgb4/8IcuEj3s93h/y7mn/wnGFVRrf2e/77PSn0X3ES9/eL4T24TPtFa+y5/VeT/GK0023F8av/rDPBV/6f5rSAngNkcr8D9R3awUq+UpVVV+MrOr/G/5C9MfZ77rmSIKPzxHl7i5TVYAxf87cP/13148r8GRemXPnxDatTUf82HVzVelCv/z/jwl9Plz4rGfyJmPjkbOBkZ5qe8dVH8goMhQvceoKPb/JRxiPHK6XbITxAXdWlRnRe/yn/bMBtOIcCXX+Ij7jf/bdCP7Pf13ZEqkh2oriKNojUJBLRdrq5fUNlus1g8PbuvEuxlb+/bkM9/5rLFLywb/tvEt/RnVeAP6ztJ/HNBrW/UdF9Ft//WdH9rur81XVma/HU1XeV7NZ3edf8fVnT/HPc/ruj+RAz4RxQdz/NvGZyvPbv/srL7DED+rez+VnZ/K7t3ifLXVXZfBZL/Vnb/IWX3z3H/08pO4j72+7+RgP+ymoolLv90FvObc+//00LqH6kpxfDqH9eUyl9T5L9bU8q/JcmkavkSfzbbWhXlUkXAWxpXqn9VL/BfyrZKP1LQ/0Nkzv0VyVz602T+PQX8NaH6q1L/HXWKEk0Wv/7V/rvU+VWG5V+pqvy7zvJ/gTH/d53lX7vO8j0eLfKVn7Xvpd+nbf4WC3+Lhb/Fwn9FLPBC5a9Xfy39SIPPB0W99YJ/o/X4Z9tj5N8g87/THoPL/NdiqsuzcV/mYhcqvhZPu/+jvOyPQinbNxZmvPmf4vE3UVPlFE3SvuDfZ03hr8Pu8ouuf36YnRefgo/9+rs4V5ZqX3XlsgiOVPtg4pr8C9MIJT7+tjDOV/mqL3MWhBTOh+MQ6f87BzDwnEB89Vppt+oc/+uLylnxc6/Ivn7XZv2JBA9SIlbA/iHl/2boQVFd/oY53pXY85YKfsv941Vuu5hdiYZScpBYYHc63OVBeFnQei1bmKaqMBuPRHdXl3oDMzF1JbQGysPcqNJ8/LgFGbeZtfpcoB/uXXEhLlJZtFL5HuyCuxUpiaXVs8Uu2PSiR+JP+gez6dbNmAstTUosPaafsjP0QzdVki5929SAfsayvVGuz3Mij54jOrpJP1M7y2F7GMJvTNF+/ibgHY1+uomt/Q5WdbL+m60r3PMz86QybIgfsGHR+3GW7j7vaw2DJ+wMc9jOv+cd3c+Pb+Aew1DIr2HC8Zit+x3On8OTnNJzBNnvYbhHZsmv5zDy62VBvqe697a3sWS/9vZ9r99gdWZqZjjdbS9zndsEu8Wmo5u8PUhCgg++qympPfSOhAayRat9nwle3dxYBJ9K/h/5bSD207lw3XYni+O01T9Q2iH3eFi6KVpZLFqRT3BLnmkYpPbAvH9BH5w9fO6pRPAAz+Dy+VpD0Xqu1ZXt5/dhji89fuLPITiB88vHh+4jPx48PuAcz7yd7+PznhQGfEvknA8Y8PaAe0j5p8ezfdbeYaQNj3/edxjDunM8fgkj/WXIK25Of1rxnq6UP0fI6PXt+Pu+FeCvcO3JBMeCnSahncXfjWf5yduDfG+cYZgCLcP3Oe1aGwVxVPheeThUFpHfWboh09+BHPn4/ilLhjG7z+v753n4PV43AZxxOQ6LMPs9l++59cSlNbSA3730CUeByOB83SnwJuF5syRTvoKBfpnssjYlWLDSd/hdtvlZvo6Q/y2cr+sB8hOuqWT5vUIO4DT/jDmHPbuZ5bjK6c7KZbMMPJLkvyfyJmXnF4/LOW3GQJuxBHgXGX7Lx0WAgaaLMNKMLwPf5+vSvUfxmQkvC2/PTOR4Lq/tKOd755/AwOcgJ3GfGAy4KcLI53GJJoguyH4L5+uS7KhEEzLoFcnKUJ7nnzaTcUSuvGj18aTpAfAK8MAnT/04z3PWEPUpo/UH0PIDaB1h0O9FGGkdeVABve2BPLcy0AcSg5l8d2E/Ac8g7y2Q7w7Tox7Ty7lMwHUwGNZThN91v5ECHQA9Go8yXRjvtPV2POA/YKNEnxKRO/lzDUFv6aAfQE+96MTlkK/zdVvAZ+Ejp2eEX/tLcCkV1/GCFekDZnYS8FeOs8RBnA7ecfzjNEh4DHU52kgBz3Q54CP/JLz1AaMNBjQ4dMEWU0B353YqyHTCrzHeA+Dnp+Dk9qAMeBPhWqjrXvDz2i6sNz9mg45nsv/tONjXhAa8DxjWkqB9lNNGAPgK0dYEXgwzm90jl6Wo94gMSwFGexf1osxkbYQ8F5f3XSvvOZ5vD2N4DvMNdj/wNt4oG7M5Pc6bSd3ct/f+JuQdYqfaUXAx9UDuDF60QmgkYjSkk9+1bC7Y1c/TAS/Oxn1uRugwtxOIfUtsa/IswouevE03k2pBs8HNNDXuDdp2rk8ttL0E1PNWFrL15Xo/Bhx/wuDDgAwhOm2Qw0BnOd9F8RsNmS+ZjzDSpQYw6Ehm70QxV6QlasfDurMPGO2VnIaRnlLURUBPJRifA+gH6MkEHY70ZErv9ER4VCrSE+XZIj1ZIBML9JSCbY70RPa7SE9m9kZPKehOpCcC+0V6KuHrx+mJyVTcUyaX+aLOAz+Y6dF3nfh2XAZ9UYLLehf9QLzfV3BZLzigl4prJtfOQKbRNf20jH/pc2ZnMH39stvS38HMjnvZ8gWYPL/0ATP9DLYu8/eN38LldcWw3x/rLMA/v6+vWALzaYt0xqOugThHCYbnhXhDCHEP71H2WYsw3sN4+blPGPwQkGElGPBg5fY8yjDejoLSOoswrEuw0yL9M19deH/O8Ua1wV9/+fPMX8fzcnvMRhmVr6MEAx+KKLessk8PfI8yCuXCx3HwgfLPEowxgTJOiI1o/hbG+AnIa8CRy1ul2EYRRvkDNvgrlvEe63jBjUNI6HQ3Gz8uRP6lU8E8EF4ictYXCS2LziB+ydQhtzEzJmuTAn1fF8I2XjTDuhk9/fIX7UZM7vLmhg7+77XUNTkznBKuGQ6KlnecoKZEa9sZKmCNh6A5c8uuBCO3g5UHVF6IIoUfMFBZxnY253aMImHUKMfEkFnvQBV43BXKUSeXB24SGbcQiZBTAEQidCMrPUcRRqrNvQl8Dg49dytffwnGCIXF9q9EMRlEXwswritGixGirzGs0+I+YKRcsACt3MIl0gS94vADRulil9ZRxPMH3memARHHKAQ8GS+uzKWQCBwAllkI0VTvjSs98KbY8dQeFH9P8A7wi96sclSSPT94FEX4bT9swFNx3e/wT1s0+fMrsoPPG4HFyZ7LBAvUgH0wwNtE2g8hQmWhtw9esfEJoweRW3gCePkCem9AC0CzAWqJDKSraA3Boswtbvz9g2k1xo8Gagewngygi3yNFkQAXs9gQNTKBE/YAn6n+8LoKKF4/QJf1BInuHpF4L8bZ8waz1jkBCNQGeyJBJkZsifeBwzeJcgqH3jQByvKBKtJSdFrBUsXzgvS8l7h7w2R7Tl6NcMAtDLQzNB/rfsdxmhnboE9wBLLQHYkYJHl5+sheuACePJ4nPCcVfx9aV+YRQA0/4qk5JklQvOwztzrsaI8cvWiFwu9yAd4Vxl4Ky8aHJTpj3k5SLP5Z4GmAS/gTRPrDvbbxygUeAywl8R7fOMr5EOIgntvfGoJHzDSTARrw2hHvncJ7CnP9uQvYAn4kBPxRKapWCw41zogXQWL2bQmg0FbZLn098G2tUB7eMAJFvjrr3sQCQG2LFBE5sKuBynAYHVgvsnirZKdaoHm/wrGe4CNOAQ7GeOj6NcgJ1IY46SQ/wI/RgDJyAMGBYgBPO1noCbOLq3DB0r1QPqS/c33QnjtL9jgeY5UsNEmz7V6Cf5xLaZ7HNAEShLOzukCtQSPMb2vYLAOAP8+0AmDuU8YrQOgj8jNyuv4An5bl61/uc4S/NP7SiQ00L3HMwmdWy8Z+FoPyIkQuCTBs5dkMoC2LJSOIIng9yBJX1rD5crHwQrIfwuxK+u1nlzys+PF34IFWrg2xK3YcYut9QkjD7PzzdKzgWzJUEuBjMnsCGUMWgyWyLRMfk2IDzAYrcUENEcG/l0Bdt/ggN2DxX5K13QT6w1+5UNjrJ2QAQbrHnCRW88pe24GF5/JhTihAXFol2lDVnehG8V9R8v/Af500UITwY8WgCYQhmsbggM088JdgRb/CP5CYxVqI8Ba+36tRZ4KqJVJe6Y5PLAtFKBYNwUYo3YPtrs6UoBR2m3QgA9imwOMVGgA5lwBMMahH4v2CUStHhhFsyKrZLcTjQJax0g/YLSBcorlgUsIjFEd/wNGuxwkaZpTsPFAmwXWCRKQnc/g3MYLYR/iDxg4A/x1xhng11mpXaYqJsExsk7suEeZitzsA36XgGBrgobMIPNRgn/abjIg/uQxXrVLNSYG5M5cqGMyQNe5rI6JyEzwdPN6LFZLQa/5DiPF5lYxYu4BVi+H8qAII8WCjIHIgAFehQU5CQNzmkzuI6ZB7kssp/kB43OEj5IMBD0DlMFhHvsl+114LhM4wCs902sffzzeS7wcpDj07sKMSZ48ogH6Ee3TkOlRVtuju6iXH8UIB+ZnwC5hutTRPb58HPX48/o8ym9WOzGMi8eF0m8j4+3aPlc+HhT1tGSjF/XyflD2Q4w5z+kTGLylXP8RukS9i1GzDPIiMuTGM4dF0QBmtU9KBjSYAgxrxNwdOy6+7mEVJRbCHNy7ALN6kQRriaDeL2G5+RzOz4/wuV+wVYwEZhBLhtolzAOwWLweAo59kOoKeIkmSnnu5SWitjFKsKWjh/3HtPd7+C9Ry4jPlL38uDBFf41FfiGCBBFTGesyXvwTI/4f5b32gSYDkKOMBiXINXNAz5LNNGq57uVVhxMINkakNfD6MbK5eYdxXVB/A5r8BZvCB4w4x/tGmIvF6FFOx2/y/gU/+QAshgwiiEUY+QLsasYXwP+vWgSDaVh4bqyLETBiDZE60FdFGJ/behQ1OaFtsWh5leAflt8FXYKytahrUPZy4HdINlqALD8Zog6WivKT4aQIl2vZSnW8BR1dguEerG4Iouxgu6OODhHPTOYDn4OODh9OSUcXYXxuQy7qaNQxQOv8u4520L/Wsc7lbd/+OjqaZ3L0letOWd4ut8Yz6x3eIMziDShT0lL8AbNIQ8iBv/K44Hu7gAMPrw16xsM4UMJqFNDugagtWa9UhiEi+XoOyMSZ8KnA+VA/CXqRwO8ZI7InJsAgX8Dix5iexegb42wGZqFwLfi8eJzVigMd86CnyLXRJ4V7YQ05y5Z5Ge4VeEu4/3BtC+xHVguL10TvCnSGi7WIKfq4LKaG+jjC5wM7Gbwx8Kv5Fy5gT5HOhyba87inWFuZ/hWitBZqTZFFHjKUsgH6ub+HMd+DEaQMLE7QzmBRYiRVekVpfbgGVrywzpG0eI8nzHJKaMWYoHl+D0NkF6oLMadqJRjJsctwYV2QP4AolIUWMjwXszoI/NORPjsCb+6FB8Eur1eAKM6XMESZ0f+GyLrPol/5PdAKZ9VfAkSwM4hyCeDTIx7wHgU8+CKLdmHkevM7GCPZELUHerAjjAJC1QiDX+sCDxZzY+AdsueCezzp/Kc1C0qtVzVh5gP9QzUhWKQETsuwz3gOYkscdAFkNko2hPN7vLoGsHoaqn2IJMTKlqwMs3ukWMliI4+hNERP7PUcX3kFXO4VuBn1Cqzv31cBq/RspiGCxMJq7bzaC+JYWL3NYIFV3GSmUNwnO1OyEswqB5lWEVA2OZihyHO3HGZ3EKZaBHgC6hhwHT5kKnCdvsiO/zX2lofYYEY9VayytzDPDGuG2B120XDMwmfV3UjfLuSqPYzVcR8wq17G2B10OOTyBSwxTwIvI31p4BD0lIlZOrim9Qkb+GxA368uBYi3WhD9gJhiTlOo17gXzVhvz+H/Hsbnit6eCyp+MaMJcjh9Za4CkMVopcUgGyzxA/7pbhSQQS9acVHXZhjftUq0YkKW9UUrhGfkIq1YkKHDPS3BWMmMmSeoRAarLUVZifqc8iOr1t0g/YCnUsJ7EcbqRKVAPxgNChn9PD/z38AacE2sqhs6nizQUWbGYO0dZrH+t2cHHkDdC/ewWHeVC5aslZazNFgTUoJ/OGMZA7+y7uAHeBMZ5E1SVsWf7w/IGYNZ6WQ/IEKMehQiCdipChXVVsRqvkSoN0mh5kuCaDHasiX4rW6KRkofz84+zFmUbA7l8cIbsXM1jn6HnQiAewPqlYow+w3kMaDGBTxGsqfYGYk1LhyL3gM9YH7JjiDCh9m8AgzRe4jKWODJGDLwI3xfhDG6jhFaP/3A2zv83/ds8r548ld5dsafmpvwT8Y1fA5O+LmRCLWfmAPzu0Ee//G5hqWphn/4ms9vH0LzZ9+NKP1mduy/OcKQ57jKL6k8jKgui7+kwljNt4t+84vgpa8mEP+zeTf/1tDrLwYG4+n670bi/OVGXf1m4s/n+KvlbrbZ/l/hG+e81KrCr/LIu7pQ+/X5Ir6vJlsh5v/jEk3+kcHG3yzR/k3J9McC53ve8VutCb/E8tQkIm/KV/nm0ajyn3iB398zf/7nM3+gir8w8wejJK+ZPyxfpP0O/pj58yjD2A1TgN9n/rD+qJJH/5r5U+gtxE4D7NlzSusswsz7l0vres30+YSxn5r1URshziag5xAP4dWrxmallPrySvv/Dn9b54Eek72zBDongOxZQp5F/t84CwbmoRTgj1kwUOGFvfAu9jSLDH6fBaNDzyz29H8Bu1jfiXnqQQnm7fQdZnXfOF8EI6j8b+HyLBi4JpsFA1GQ1yyYwmwXnAUDHQ6luSWvWTAbdn7x+GvWC5wHeH/Rb/k4m/3yPhsG1/OaBWNhfvdRfObXLBj2zK9ZMEPka/e3MOPz0uwCBmMNfAEuR/mQJl6zX76A32fBIA+zLjfsHcZZMBhFec2CIXj6yVkwCZ2HQbNmlh6S51DoDIff5vRcyEoGyauL1MDJIlwZxnhJAWa9ixAH0Us1ndIrK2syGKvxocM4xY5rqDrAHkGU5oXsKk52gXUwGHtlCjDrBDdAi8CkODYZJI+1w/mYN3zB9JPd/xmvYXFYiMnD80KPKVYkYI9bXgHBMuK2XjyOuawQK62xJ04oTMlLy3sOcCk3FrzFSwNWTWaV8xlFHP88xeHupK9uWaVUs0MoDOdOAHXhzAnWkQuVziFmlQCOM8jQFGBG1cCDbjHqLNtvNXsvfrbYXA9WCwZwLt+xTpB1XGGNv8yyi9DTDZ2zJdgtXTPE2jKhvA8gNyj8LVEyhc41oRl4Wm0tkD2hHPJ1BnnIOkYFlkXFeU4RdHxidBS74RB+ZZCxNgjwxmDIJBdhzO7iTB/oCI8UrMfkSuuiMON6rM/E+ZBYn2kUYckp6JTyOhic4ZypF1y2Hcl+QP1InLH9ed+vn8Yd0tGwmIWArnmcVQI2gcUy8ggXsjI5/WflWu5YsD9gxtdQM+iXZnuinfBaF6uZZDWDWAMMtrLMqkQY7L3zKayDwZg9KsBshgqsy8LKEpntz/t+/TTuGB2xumMBtSnrDwQ+hL0TGG6ZHe6DT4b1xgxO7fQdZnwHuAL6jhSUd8BjwFMs44hzWEp9mzLIggLMeE4ur4HBrykBDGb3AFsf68Fw5ij2Uhb26ofxxqqS6IylclWSAD2WqY1VMUPrAwZfGypRfMieG1x5Tp+Jml1gNn7E5FDuN+vQIZ7zsIQ12q+5JKXjIO9wLh1mNAPpZZlAZUgWomUCNrTy+ICZrEGry/hqH0rwl36w/vSDiR6mfrD1P8sy/nk/GCszpFeG38UaWKwWwCka4MsZOPNSYlm2DCxPyEpCbaAAuqcEI66hukPC7DbQD1bSwR4x26F0nOAFK1DSD5hZ6QHgAWae5pk+EXybEsxm/oKtBvSAM32xFpLNGLMLkzzAKhadlx4AjwFlelxcRwlmc7cGYMFiHwtUoLEeTgajTIeaSbSlonz6ANEj8geMcYA81iXA/bG3Fbv2Uwut/hdvgXzBPgy0f9DKBy8oYnM1IWMbYsce92bfsX35lgrBP0/zApM9L52BsgdnX4HH5uO8zALMKnawEhDr0mE/XaAriCWyTkacTYn0hZ2CYfYJszlUGGOAyhbgVehXR4/PGb7me8L6ZdQrSFs4H+AFMz2D8ySBxzyoJIQesyL8ZS+qm/e26BaVW9n32whsLmShdjm3EaAaT3DQA4f4JfgiBVyE0M0aPFicFHRPvhdQe/7CXfl45KM84j9grHyLUIZ5YIejPHI/YBbrSUu4QlrEyU0FmNW3o90NUQHmW8ofe0VwV55ERWjkY34wdMZGLEbNfcBlfwifR4B+NgHovgQjj4E9BnqZzZnPrwO4eo895+t3cYIIzqfFqEzKzkfdnt+Xxz4knBZjs+kxbL9RPkH/AfY2MR/ntSc/bhdDZZXuvabF5PI3wT5CsJlE7F8B/4BVzyCNYFUK2kzQ4Qs63xVfvVaw1+CTknUiDsTimgrvLsjQXoMKnqJfjFNlnvcrTzZyxbfKGvh9EWZ9r9Cni53n5mtf3vfp2/rm/zzeMDZM9rww3Si3C2ACGsZLyL58wtiDmO8/4gNj7Wy60WvakYGVqGCPRTCBCHgS9Ab2ZojWa1+La3jgfE1r+AnjzIP8d9Bpnhkwi8EqyaEnzKrjIU6PfbC53HiwXszCvrDpRjhBQGdzyzPwsRIbO/WBvoCOCxVyr059qIR9YEUX+PRYnQX+OfpqZoKzdXAaGM7dBj2dsNwb8x8D8BNg0p2O83tQJgFOGS9i7w/q85it+R1mPZxakb99iK4qrAcK3q3xXTM7/6WoJ1gOQ//VeavjhB3IKOKO5p9A2d6rBhi95BwTAk6NBiutALO+COQKkP440dn7gHH6JlyDx2lCAEOkzX+bGfZ+XMGMl/QBs66tUp02B1lm6LtwRbQsWdz87Thm2ZwPGD0NsPIz7G4HbxQmGZdg5B7UztCVCh3PIs7lsJjWxtg/dIqxrnTUBtArARPa7eg1lRosYQGmLqVoCQB1l2DmDeaWAXCoCdkttBiKMKu5RIsYJjnE2HsgfMCsdhZ7D0AKwpRsZ/gJg+SAvAxOvTahdhZ6s3LJVTj/4zhIFiv9gHGW2hC63jN46xB2w7KoyQtmE7yR46HWHqQZ1uUXYGYlgoUTovVa2qsSzKzX0l6JUAsM9IZ9HnH64m/suQGJp7vFvS3BbMabVtxbtFi+gt8sGOZxYRcn3BP4+Jn3MbDvxWRyB2fRgWzB/hLIonhIYwWYRb9BnkFmdwg9YGAx2iw67788ErDyIZJbgP0PGOd/4voh+oC5Qsy6i2xyF4vsBnB/nHaBeMbeKFxnIL8sg7Aoh7NXNgN7pbBPh0UncMpaChZiWpK32Nv2mi78dhy9R6yGgS7oV9QA5C/IW7Tk0NrRQbNGbFYPWqyChRMGwAP5CmYzWnPv/VHmHf+l0RmMOEfPx4A9hl6Sr2CcZJfLeQEiTzybfhYZHzB2NFvoGZWqeHyunInxufdMjA3RFZvtN2T1izDzFNwijks88Q7/eGQGJiG+Mh5+hlkqi01NLNoGON3be2XRWP672GuLv7fYdMAXH2FHMs7wRG8CekbRhihMRoR74jQ4ASd4g8VYgFkWBngTZ+9iFABnZsYMBtoQWa0A8AfYKSAnGM28OqwjnBqPPIa2CUYL/Qc7zuQ80CraVSnuzTv881YlzlejkohZQCAt0IfHJ/0CZlxqMQlXgjPrA37j0qw85+0r+H1d+H6gt3UW4Z/O3QzxnTuvibDM38tw5h1ai+izucx6ZF0zOeVDHZeBWivDOYnYLfay4DywUrGWDe+ZT4YDi+MJwz047MyCa0KHTgEevN+DaUK4x2vd1jNvEbw/R4rXBH8Lu+BgkpwiMwnxNtGOzQfTsdMfj0Ns5HUP8C+xA7sAvx1/zfvCeUzh4w3GdUHehHXesnVA7PNhYb0fm/eIuW82xzB5zVoEOOe7lHViQVz/NcfQBD/WB6vbfVmGOnRi5bDMrHANr/G8JnbGyn+eFn8P/wXqSh/MsmIWO3tGnC6Nk7nZviK+X/EYIytaeBbOH9ExZgGxH90rdLqBlzkEHkVvB72bIdYWM08CLGDv4/jX57O4CdRKIozzmoow89IwDg30gHIZO/bwfWofNJjTM06xRrovwmzmKNax5vW2bJo1xpeQPljc12QdfhgzBbnBYiwvmE26hPuawG/QVYndzUX4h7UkSl7W+/tgPqQGkhiOA9bQX3z1F7NpgTjHE2xK3FGMHGtlKYvHsdcXI3NFGLCME/wkpyy5hbcZ9+JH5DcLsMcTq1nSD5jNNccqGhP2wYB1IIwZWUZ9uDcg+Qp7mWsoRq0/bCdLbA5Lxvxp7L0H/5VNRcQqBw6l0GuqmIfaESdO4Wx4nKj30tCsUhjnAeD0QusNdhmM07zKWjoUypZBKLy0Oq4reJTXAVKQrTMQGAzrclhfPb7PBKotoKrmNbWZTUAsT1AD2oDnwczDKxuCxzO3dByqw2G9wWsqHFbOD4rH8X0uSIfoT8Q4xR2rLrEHHavSC5q5vP5XlRnE4bBKHquG9FeVN/C5gBPP3t6bkjLpzvCA70lx+SKMExn/BA3+E/jbpiX+a5FqoBCLvTXm9ZTwvj1ma8HOIubxfAY/r4e7Djk2fGcS09PSa65bXk9hMz2LsX98rybq3QAjQx/Hvz4/fJRnfyHM5ioW4Ld8BJurGJf0tPOhp+OSnnagToFQRPoBszmJcVH6ymCvZKyuFqmJ6WnsZXrNRXRwoon2DjPcSUU9/YHLIvzTOZJ3PS2V9XRY0sGvqYdu4Q2+cD5UO7B38uS/xwmHr+u/HcepxPg+pxfM5DDsJlb/4qwWv1TZ89TJ5QmrGb51DOKLr2rwAswyW5ui7AvROpMYjJWtDMt+qfqOcBSjzvI+/nzMgs3he9XmsxnT8ImRxBcsMxhjFmzaFGTRMJ/IYhhwPHu9Ww6wjxEgnGiFsxXxjVivGAfOUsR1D0pwymYrsudgdQA4kxUzY3L503hFYeEe5bcjeTjnNWH3eL3BK7/2oHg+m2kI13ML7wY08H3MsBascYYpYRi5f2U68I1pOJGKt3CObYazFTF7x6LJOAEHomQexoEwKoiR78K7AZFTcX4j1tDB7ETIthQmSGHNIUYvcX4jRkOLdPXjVI42psW/3tyJkhZtSu/3MFaPRDhXpjxbkM1oQZhVQWK1KMopjN+GfPkeIc/uAREg0MIZ61b5AoZYL84LklhFDcwFdLQyzHIsw/K6cQYRPhfew/66E/Yn4u3Z6z0FGPO1XhWRv4Ux7oxeuYc9ZbDvmENHGO1drHDDPfRl9Adxpthrz/Ee2DUUYsXd72E289Es4lZgM7DwrT8IsyoOnJsF647QP8IcFt7D+/kuF5hQbNOcKKuAw0pnqECHugOIlDH4JZHxPXUoybACGd8Q6ksvGPcI83QBvi0ItBlWHCP8qlSzseI6w7qA0jqxWvMJfxXBI3STV8Tnc/0e38wTGB3mXrPQLB5znxincJj0LcH859xKllMq7Rv6sk+YyQ2syIPZXlgxF3ll+DWdACvocB2Pt3VmL/hLfyuz03xP8wpe4/utXsgiv6YSOjhBEy1G9s4lsBhx2hjDBlYOvGYvfEyIBPiVwcd3BuH8Y4yfQsYYohaF2cYosXA2MUgCeJMdzDK2StU++B6Hkn/2gvX3qWmuVM4eYt8F+pIMll6zJQJcd+n9H9jLj+8iKbzvncN4O/TQYNcsRrhkpjnfomav+jrUjFhLiHV4iA8DpXWKMX2Mb3/CLJ4K+2fiWwpBsirSB8zyAFjBFALs/xbGuDtOM4a3PGIEDPuJCjD2PGAlFc75N7C+kf+AGddCbgzpNMOJsPEH7GKcH7gVIzLlN/sUYYb3UvzZxqmKuTfFvXDyep8yRtnQ1y9aZTjPnFxPftmofvGaPMMp2J+fUzexnxArD3ASI3TMF2E2iRj3LNfAGLuHvSvBYI3IzOqCOv2ypCvC2JeBFibGMky5SFslmHmtcZGW2HtAvoLf3gsCtMXiSPgW3gL8HlcywWtFmfIFzGr1XezHfr2nT2fTPaH3B61c6xPGKjOMuOZ0jxNGedZXh9F3NmnRwKnS2E+E7w/CCZ+g0VET4XsRXDb5GGj7wd4D8JbTxDnwGE2wWbQZ34NQeE62HwFmBthabcyBsjk2QSmKjfNtCvshYiaA5fzZJNJPGHsFcMIl8APms7FC4vP5hmA1Qi++jX1zOutlA9/09V4Q2GOo5w7lIg4IDM/l/7N+T+m/UzMA8UGd9YPgG9Sw1zqFvcX5M6mNkzhffW8J6iSWASjlGS2O5Rlf0/HxjW6gz6EqlVU/4Xu94uzlBWA+E6xklu80gBZgnWxisQd8BHFJnO6MvVcR9m6zt0Yjf8l2WRZ8Ab/LAuzxhYm+Eb7jycV8OHu/DsQlEofJzvwdixD7KcEoOy2oCYc+G5DlymvCN4PZhG/Iu2IVWf5GYrTU4XqvuQC5JZ+wfs0n/zzv/ZRbLw8Av3efvb1F28UqvG0cbKgE411gESeAixLMZmPka8LcLegJBWxCfBu2Jb9qKTBaALKtSMvv8H/Zy+uKtjDNpH88Jzn+q7Na/wezDyv12i+5Wn/9K0254zmh/kv+HIRY/2q2a/W7JiEKH6Pv9MOAfGHur8vwPHtOtXufhUe26VrewS/HYhZnaMJXOOVyu1xdvxiAt9ssFs9Zi8l6c10OjrPnqNTkPDt+zF8szsB8n5FZ+UacypL4q8qXECnUqx9Y5GXhl/AFJgXuuxAp/vEMQzqJ8PjbjQnyeamzOZ7O/Uc2TBTLmyV/bhah78+dEr9tp6T/f0x7PO7DfwjaZqQ6/YTrNMODQv7ZA29teCH5i/6nmIqm+ORT55b2sEK/UVu2Nhi5pqaE5kpZxxv6pbJNBo1tRn/PJ+TsRaC6m818+fzBluuP1pwn1HeL1mId7DxlIS7E7m57m4l25E/UbXdXT6fy/dZTyRo0RRl4fXXU2gTVBS/eNH60X67Ieutpd981Nx0z9F3BP/kBV/HDGae1r5odzlxzEN4a6dY34ol+t6eL64zwsMpX+2feXyYr59bzyUUmjj/zF7ftattvNrT5zJ1eheqM171Nk5x95/trcpKv2XKaEO5ryOPqakQjJT6FAqcm3zJ52d0t6rPt+HoddYe2sRkcu4+LseaVeDO09+FIiU+uNXaVeBCcXNXTBkflMVW0fjs9Kg233Yn1Rqy0jX0nVv4+93/7uWtvvb1PFpy62e53nNeK24PJyPZbyq3W7Ia9ZWN6babHoOHabevYNZecUpW6SeOmHKS2Puhvs9AMZ7PjYLys9xxCY8pKPQ0aE0p7mvU416xN6rWVm3J0tuZ0zq0CZZ5Yt2rlfjUaF64Wz8bcWDHi4TY4V5S5SyRG49G8EJLlJtFd2DbD2pWs6zzYj+jaeaNzu9ZDpS215/pRPVeJkGzYcRyf3XUjVNudaifZO3VOskfkGo3mqhNJze5R5tvcZUr2wr0O0vP57uvDsOOoWX8lz/uBcOCM7D4aDBqXi2Y2t21vu18ZYtiq6cdwk45qFSIdVSXjku22424viWqOj32P31+0Y9K5q9Vp8IgXtbsa2+fj9dKJ992p23SvRvN47gWKGFZqymJd2fXrmUUWpasbfzy4Nk3y3Pvz1jpWbHJ59Zp0W4q9udduncNUn1TId7XtYNC+eJrpxA5Z053czmopi4tdafYWS2t1GlsXpW0qg+a4bonKINA6CbmB61IFIajH9s7uRY6q13tc2PeVztA6zrtLYxJ2a81p+Ei3QXK+knNPPrlOo7Pzttv7ULyJqwdRGLJ7Cx6jhJuSq6njutkZK1qcdQapGErS/u4Ty62RysP7NTtNo4E0D86TuOtajVA5GpXd5Vpp1A9U6l2TppTdnOE99AjoSPppsL0YmmkJ8sgmFFGprnuPib/yZ5RoYjdr3h/r9XU5vCruWGluHbrmeD+f3LeO2hsvaOB3dFP5oUMf8UTwoahmkM5G9f6Mo68hEBoDSofV9d4z07vd6am815psz65JKOQ4PJEnOGbCtFsdhl1Bk6oRNyQ/O0iapHsBWbmd2Et9mjhVVZirB5UI91ZYrRBitw8JPcd7njNaav2h0rs0HvxtvrjXXb0Rascp357OEzNQpona0xQpO/ILRUyOzmEQR4tL7By0+EG32YtPlZqjthJtdZzc9F5Nm9FdbA/bSud00s1T7V73Rqfdxq+EFdcmGF6ElqMsklb1UZlPD82bep+e+1J30VdG7X7P3Ut6TwunUVozB6cWr9A3MihHpTLbtcJVsr8mhLNUczi3DntJIgcb6rK2W00bacNRgnHinRY3smql0mlPeE1qiEqyaE75XSxIap3uwJE3VG/bWhDCaoTH6qxnVyS9vru3kt0prHnXQFZSlR9o57NcN56CgKqd7Nw2Vg+O0Iqq1aR9RD7rfNW1A62a1M6h5J3z37nkd9O6RX9nknMc8jtrSLlfqDcmw2TEr+ZKJimL6r0/Hwr7ZlJVA++4lLVY6Wytc6U521F+T1fJYnUgBpbaqI2dVXh32qeqfpeUE98mPzyfrYa5ohuzVrSj3iF/bfbKLlCGYcW23FvU7FV2B/vUXJBrETtKnawpU1I9PJ0MOseWnKhx0z9sV96QsD/l5ZUmZtaKGCKN5B5uasZ0EurBPKEP0uPkxjW4XZJ4XZeIO2krzvGs1rvLGrUGIie8GORztqZ47wwjfZetiHGnVu+UfK/xypasi7cjf0vGSeH65Alb3KQaXOvZ4WxbyWrMNZ983qg5myOhmoMeBpdxbTYWlbB+fhApkR6bsbhXmm77pBGZ3js+RGlPrKypTzhTbWZ7qVuPfJEu52qHo7FC+WfhE9tvRJ9Uo086Vq/6XbwPQik9u3xiOnNuqbU8tzHwlpw6UfjLze/fl0KctbbT4WYz5DebmjpYO266P7np/dhNektqudSMleTc+mEumzS72uGSPiUmhez59Sg3m53tfaW44dlTTkT6NchSkrZuKpPWbjY5HCf1ObeeKcf5Qay15rNx/3Cc1SRlYN3Inc6rkT1bqcI5PC5dnuKfXKAfKfOVZsmJ09Wr1pl8I85nu0M16rUpHTiuFXY943mvC2F4tcV1ZX97srqHxCFyVG3TjeF3o7OkONwlmw6u+6TtNU7GyJ5XlU6gn9PuKnw+w6imPblkfQqV02yz1O6ZrerV0yzgibWtdgMqYtT0uBUTw2zySud2rxJtegsHN/VQW1drykoaOMPwLihbSnz185iqQFkJiHBNtHhBtXR/lejEnEycuRrMz5v+TddjiocF11v2KkbvJinCOmj3ss06k+3Kox8rs4FwPFNB2U/vJz0xKO1TbaOH96r/lI1dycr2lhqvhMpi5x9PfEIJf39151mompQvb4mzV0611jU8ORw/6VPyrAtHXRQWcltQr1W9dvS9wVnc6aaWbi/HSp0fjuWkMVEGq43UpQJbFB5Ek990q5qMhabU2Yt3KznGmredBcpIG1Ptph6J+8J1A5vIVMqZeqW6rw+lXnhcrZLd1QmMXnPb2ad1Po4sJY0PpwrPD8bTGsU1FfvhfBiR2816of+8kym0bp19ziPGqOHe9xQV+4HMR8QAet6Tyo6hfaK/psv0s+0wvXdPSdMVNnaSTnm7Q7l8GIuN6bjh2fvE8PXdeHM+t3xN7PYed7pURVqt3LmkO7ks7j8kKUxuC7pRs2AbWVLjOHCagWK3F4MxRewqvR84qV0/6KL8oOtXl1G2jJLJRG2Op4+6SOVAzNt9cbJs8nJPcxQnvE27neWWeG21kaNus9V9aIpG9dEebk6nnt+cZCttME5ae8VfWbX2caDRO6pwR/JYx1jYrWrVWk8l+p2Q1VBaTrSmnbWq2XS9jA/nUzPXqVE/a4WzeBJfCc1Nru2NfKcb5JtRZN9b152lXil33Pbh6XQSlJmkL5RNFUS+ts4ma2lQ0/jjlO6WWulMJzxR0o+rVr9W+3EwfcgLIyKyRwwblk8toqRXVS6Bdjo41y4xEtQgUnqjTud2uXpbKgCN1bolXPh5q5fuTXEY7RNvr25zxE4oR69ngTs9zKaJ7mlUO1eCenQPiBVI1MRyvhgFKjEsjPH+YqyMdqwSJpO61Lmy4qxPBGTDPVbGqUhMvfqiyd3qjbquUN4fj6JFpMxEdbz05cWTEibbK9XPh9g2qbBUrFA/HImU3F0tCmqUzcl/yaSR1vb3MJap1jTqhh4SaW7P5lFtLyn7vjvrt4NkquwpTo+2x8eB1kuopRX5PXKfc0vYNF1KBkY7W/c1pTHlo7lEKKLVjxfu8cBtk+bJeFobnVqjFe4vjXqyb2QL7ZreCfXeovDkbnZE2TdXynVrTPlbb590q/ayF7Uqmj449kLT7TQG48Eq9OzzdbmuBbt0Wp1IcW4dWBW7Guqi2hPWC1d8GIt+++BNE8XzqUWRta4JWajqCnp/VuuuiaW5FnhrNPTGfOeWdNantF4RQ8LzakwfKDMqG91t30Mn3HlXiju3G95q2iRY6fdNfImeLNhY1/VF0q+HAyqjahWVrJMoCFsnmlOpO9MV10tatWZrPVv0xZW79omob9zOXW7O2cudeT6NF8b6aFC603bd44S/SEoWeovWtG5UJm7vwklJ01/dl8dKu9JoqUe+N55UDKJ15POj5swPpt2mVoHaCY8bYlEuHKKDnF5UI4sfHYbUoqhYvUWuwkThkPGpcAnUSjTaK3r/cZqlq2Hr3iLHK8ND1z4dZlKzvru2witYTzHlI1/mY2qFVC+De2ItBTOlv3gcFv3G4UZ2mLOIrt9zavWR34iraI/Z1ai3iCXTjU6jVZhRZosuejW0F01Vbo7TR6ycRyGxKVeueKd2hZWdutubK/h2neLmODJVb9S4D+f3NBvwg2ZjvFH62STyV4NHuuikxENTUnlOn/5I/Bat1rwfBH31qNftSTehprlyouS5l9zqdVGbHsnVxNl1u57Zak2dWdvBuE8Mv/okZ0ti3YyPLfc4qXWpVFi24368k/Qmt+andTs6mdfG0FGaUd/b0j2xidU5EkKxNmkl1eqqS/dsTKypfejVOnqkt9vpcLM7VSlFeFGsH0yeiBXii05n9XVdu3r1E7dqVmtWtq0qp0A/cn4rns61baLxkxO11ymxKvtQ1u9ZJqsrRbzwh6Bhe2Gj7+kJfWy6qVntPj+0nY4y1cXaoOX1Z136dIbjKIm8X+1l80Tlwjq16EMm/t7i6+Sn5+4yap7NEzezJGXWJlppWaMmYHst3Oe9HcXju7V2XMy6wUrc+Kfk4S5EQgMT6eKYA6FpJepsX7WTWrve2kvckxuDIBGfRmWoTI6n7kmoTIlX7jbc1pJo9vM6abT6zrD/0Cfqekox3b73q/dEPD9kk7rigdBZW2PF5lUiVY2zRddC/+sdKjX+ULN6oW4Gaj05O1aT3x87ncpSdM3ZuFczZ7Wq82hL85ZrDj1vMtm2qwNxoDQXm2X9mCinhV3TRtRtN4mcX1y0UaILm4sQbc67dTPkGjHd4YhSkNqNurquTxRfvj9qdpdwW7dCNdt1KjTBLqxpe1G4E4Kx7/I0UgYXeo/FNG64xPu8nCUaxWq0OZ0o5DPxePru1OzPvUnVqwycSezVnPPBIFcdKyp3S64HO+tR+6y3Na3HOaCy1Ol1g+U9uxB7oNo3ibFI/etGe9pLgl5d3k7sxFxFYUpk53Huq4OpuVCWrmos3O1honF67iNHy6UuXCr3q2apZ0J0d/XkE+GgtxvO0Bjzc7J3lUC6KPpBrZmrIWWe44LSySoxrgnlhYrfcioXtz+8hRbxmqeUYtS92ly4fC/Lxj530xMpEZ2Hr5g1+0E0UX3vdfoz5d7mqAFndql14Zy1yXjh0D9rgnxoe6OufDjVKgmx+U/b+ai7debzbaA3nnbikPOXzWm8PlTknri/rDubJfGd41kTKLM+sXjy/e1kj7Jx6N4DYf+0RcXF5HA6RULkcutQCaLRSVJbfWlFlprQqKPxeOL4ljTvGytzvcVFlblBK7YIytq8x1/vw979TgVD7yhZj2PQkLN1PVDHSX9Xqay9rpy2zcMgoYJiECtRelpMskBvSYkVm8cT9WR1PyUOKV3NRVZW1dpoENFN3fkx8cX02nx/uXduM26p75P+dqneN8GyZi2VfjJqeW35kuix7HVnQy/m7nxW4R/1/XHWnVnDk1HbeWpquNQLUFf9+eW0oX7fqE3WWWtn1Bs/7JXcl7WV6Jb07pFSWa8asWIRD3IZ3irBeHmoLM/aXejdaVh6taP+B5Eq0sE5DB9TpddNpjdCkAODG1FThPOaA0IlNrUnNuS56lc1Xj5ORBQdFHlbO1aGWerYyvJMPYrrTbEeSXOu7laHg7DcHvbH/kKJto/EpLGeWmUtTh4GNV7vYqIRy6ky2AihLE3Gg+Y+ok5xYzEec4OmRzxUpWU8dW8/CqIB19oLhEMzImazsFMzRUFeXFrL5pXTHG9reyK3uDVn68OEho7vVIgvzsJDvi6J5hfoXp9OitJt1o/BYHL3o+n8HtWo/ZNNQv/uU6RZN8NKeklFC6k11WolcnBXVzU9oTJNW6ypYarX6dr059qO2/ugl9xvYbALd5ReLsLEJJ57byhV1r12qEzXvWldvVmnrrzYxVlzn1StWDmdDsSFl+6WZa7GS6OiTC+Uoqcta7d9Rr+THpWO7lC8i4QCV+tNeLZDZURFzimjSZSVnAU0TBNQ720tqFLFzHS9JR5vndlpdTDaxzvFDRFAlXWgZYe9fWhffe5QI9zc3y+V7lpYDY+CUpcWLa9L+HesBGlnVJ/OyVetan+86vMNjlhfYyIvyIJs7ah1ZuH9GTdoV/vNiZloxG3PvcWeeDDbx14W6lYkEz1ATeTGMbWolZCMW/QUQpCN+fTe7107w+PF1CyF2PmL2tDRuMpamG/s8+HR6UWpKCS1Xn8yMoldZLdrF6XXowmC+WC+adJgzGruTfWJNGsOlBE/dVT6RuupM6uNGv1531/RQGLXUOqNhFoW0a4eysFyu1rd/ebwEDuH1T4aSEqd+nxP13IVmg6R/eGOKPBmWKc2HU+8+3pIRXsYBb2L6C8ujVrW2krDljfMklzqm8+4nSrwxNbaH5Wr+IzlhJnEmUKi7/uHlbtqGc1DZ0HlJDVv1I00Ft1xFrj3sCvQmMTupGjHU8sni5heWvJI7XXjKdVL9SrhF1uVr7VVleuq+R2DlbZOa8RmthUaLuJGs/3heuAIw+7CZByuiWVdTWpBU+Z1S7TqNbtlKsqtxYlRs99NW8qFbGhdJRuqc1x6JpKl461rNeFAzKYwc2qGHe4d055JfN9zuGXL69WnSutA6bEbNheGXNct29IahPaVK+e2OIpbv9Fft9a8nq+w3w6jCo22niVloq8qWjrbzaiybqkgzanH0ZBj9Up0YaDru3szicIKkV+Nvdk8NOrO4xlhsnmecuNt1ezVhayzEH1je6jL4UMZPq9/l4wlX/GdyXqz8/+/9p6sXVFkyV9zH6c+dvWRTUUFRHF9mU9Rcd8V4ddPRJKJuFR3dXedOnWnT89SJ2XJJDL2iIzgKqYH3KnLo7gCqWZdrWr5cB34NobckBbc3RbsLEXiCRVXm/68h9Q9LvratYiOe+TZyE1DT1kEDQ955+q0BZOw0dhVa4d9P6rMFhGRsA0hrIeF465ZbIzJm0HCVtHfWy6Hk23HTkpyeeM0wrlru62Ls+5UC1GpPOmWCPLFkSr604U4u6Fvc4yeGK2qAfcygAdoo3UduAvIIYD9ACTxaT3ru1H1sgiE5SaV3HC/bdT0urLZql5sJSNz5ba5Ps+7sPY5rL0xnbTH1z2STGPBLYvnW3xs6hOt3VNVpQ72hYpyU43Ppoi8unhC/hFOVQPx2t65vaGnFy2QMjDTyda5snAcGbNCp6woATEj1L2+8IT5MFndyvIonpdBG+EwbqAWjVlN6W/ji7aIbWNfb4zM/twZqqNzrMuSv1N7sQeKA1gKs5Et9hWQfm1NAWWtrwKFDUtGvQp6bLzvgjYWNrzmtLjsDxMEVH/tgKphhfbpamrCrOJV/EGrMBfmrYaqTpDHiSdjvBPt3c2eImUpkhpdb1dLbshKZ3E6TrxCVyvvtIs7KAkd/NS13BuHYcmch42JqZU0czu81OOrY5zLUi+0ZAsst47UMHZV9XjrIhI3m5Za3+yqoVlsLutGbSNdJcdHHLjonFq9OCXiBTtEJcRyB82lsV8HW7+0k9xdT4nVqm9U0GLoIudobNaoQWqliraalycecVIPzaFjdMecL4/WbkFX5yrgQO9yJsKsvipNLk2MfpgFTZ6ocydpV/aHmgTE2pgMoorlKOPJ1WzKF+8gwQrRL38OgPVV67d6a9wdwjP8IuqEHf14RD9xy514G6fvjKraOABlWLLAxOmHUaETNwmraJR7koh+kstsNyna251p6zXOMwkhl1x45xjfOYd3oh99HZWbi5MyJ3x/ulSH8M9CBbq5HIZnkNPxyUF8XnpKWICd01AzVeVovO048/GhM44TGYl4P53xSm8xXc85vdoztLZROQD/n+jzZl+QK6FySnWG+UQab7zKfBKaPc2eeGce5O3i1nWGBVUBjsXru8YiAaZStkCT6UhXgJalWMahLJV3xtCVSkIQFdbKpEZ8AcQKMU7GUSrYO7BcfQ704r5n3caWmoAtCRRgzDkflNrwIjaFJtGxavHkVAr7wFt5/cQbLWdiDU77RmNqNJeIAs6QTw4YkIDd5i8LdXE7aVu7e93Zi4tWHVxvq+YRta750lXtxN2IRpSUhBG11kYmis76ha/1EWdAKpRPnmGjj1McaCKyj4l3qFw0e7kzG1fRr465HWhPyW5l7+ZhN3TOcuMSbarFa3+P/rNxD8Rmud3U5pK/6VQmk3p1q7l+TV1q68MommnH6OLuJrrojU6LUNGINj06R4F2A5nAuaAb1odXYybJGCBRjklvGXFF5CHLi9aKevjO0kkGrf5cBWlzcn3N6bSPR0syqulqL7qEKp5/coPavq8U7GVQHnK6LfTiSN/WwKo+g0V2ds/n9QiZGNrVO5v4qIVdV9Av5nllbPvTcaAv0dNWjdxSWXO0q6uaSdMF+HWT2YVGQ8phoSiM5044ae8OZ2npzkGH0tcoKd2i4x+q0ZVDBbBSW4VWZyYvJpXhWr82taHUgp1PBoPywDi6+xK/8sRtoWPYI1FrB9Jkq85O5pBHTWyLnK6xPJj7U1G3K8AJa8XyVQ+VebE2McSIc0M/Wl9CVdlzpXDiLj1hqczUdRSu0RcmB8vtJAh3RRHk5NGK3BVIdcScKsjJa7/nB7pBPIrRpLk57E+nYWivFCPEnfE8J6iA+ECPMY+yYTwKKxiRiUa7QwC04HWdUUFN4JdNH92Ux2tVOFc8zqX2Yl84dImlP9t03GJm0++m60jbaHFyamh9hbuIxmoSSmjMdy+KDvgzRltqAqZ9baF3S21X3SlOctDcy8gcGWPOq/SaC23Wu620k+DMZuUWd45jtX7ziC2vjoOm2fSWRAp7pdhs7x0foWglB7MGUDxV69edwxWRKe66xQbwnerysI2HR6QC46Ro+qipXlX9kMZ2a8mh6l7sZdKbR56QYrh6k4abTjlRJr39QZPAqqr7Q6882EycvVMUvHHoUeGL/E3iq962qQ/DAkiJdXvTLc1Kt7WPGj3CE6xbxJIusRpL0cBdBHF4mQ13ws5dO3pwUu2zqE75TaVVw6buWg/fKZWdHefsjnthstgcC7hqA1Ztw6pni0JEfE6BsrtdnUNTk4fobyuf9UJ0ATkcB9VZuASpDspdI9Ksc6E5m83Hp9KkE/SdqywbY80H3ruWglanqxK94FDpniIVFNYN8QWv+QuVxY1GPJB7g/Nm3r2GsEdaT6111iv88FGn0CuBjn82q6N5ZGMEGFX8RizClyaIC8PeTdUTtOAjbRY61TP8v2IN+M1xNWwt1XO7M1stJhZoi9oA/crLsDypAu/dzsCINEZKg+groREqIN3Wda4qNST/2htoPauNTG5W6IPBkWqQexe2pVSZF9VZZO27+3ottgvtKSgpp+Rw0s7Fake4KocKCZhV57Adk/4k1O2lHc8L491sOriN1GVnvi50zEFvH1waTb7QHGtdhFThRjABtPhaYxhVBxe1EdXN+FwYEv5/GVp6eY2htWm5ttZ6e111hpLPGcVeqPKYCXI9Eldav9+fF64lMNr17uVit9CTZY09Duwt5yJpAhdd95M1Ws29apRG5Azx2gZpMhA9qzGUgKfw7Z5fnE0rMge8zFVAiwUlIvb0w6EWWdVWb+Txm7Io6otzF3RGRU7tdjsQjuPrKpXE1aWIJC8pzXm/Ozub87MR9sJqt6BjEltZ0YzD1ihdWzs0xlrq0AKjaiaZvVZl5MEGgjpyNFXRXB2UBB22QL9V56J1h9V5pLtaMnKMRfkcNRy/M+Yid+cJSViL0DadeeONND9OxQbS24RIFH+sogLtJDoYB9fN/tAYAT6azinST7Vhnx9I5T5qGzWxP98O9WWoCQuPWzQCg+g9gOfC0eg4jrnd9IdG/XJ2QvO85wtOfVS4cLDf+iTZN1rjTq1YaY6qyq1NTMuW0AiGBueKiTNWXK8Z1bjZXKqXeKXYQAYZnaNiGc2AsXXdatX2SPM03EkicwDybSfQ+7tVM+TQOcNzcgUxZAb61HEYlnfXoSOYsJsJtZypFdOZmvuVtlmVu4tiMVo3qsNm8YpfGQqLfne1FicL3gMLUL2gVcgLXEWaHnr+aVFUz7Vzb6wNJt5e5A6Uq/CEq0wjvdoSRl7topfQogfpO3eLB0WtA7eYV6Mj8oqDP1bscaUpFM0+aujn6/E0LtYLIPwPy3Et0PbHxtS8htrJLHFl+L5WAJqr2wBL8DQ+dCOnoFWGnnzwepIOu92cG0rdONT6iWGDBjgz0OW57c6VSakRNHFUdkK7p3UmHgYi28A/0WXalED1cCet4RrF/3i68DBVo6yRpAKwD1d6f18B3bPfsiZevXjS1UY8m+zt+qgroBXRtG9owbqiugtMhytvNnqyOpfP3S6nV7oDlfg2TclaIlc/9aMycITCfGLf5GnrelrsDO9aCC9BVeP1TaunzmcYdUKRr23OhePRA209qDqAXR3inKhsw0GpLMvqRrFLGHs+uiv/Au9RI/eizoFKUREikvrUUraqUZyB7B916oIaKI6xrxTGtQGBrD4La6fyGSHbu8nHlqSK8ImDLdh306g62fN8i8SOtRqJKPbbVt/tcOj50C7VZszPQm7SVxqdpTXrc65UR89VebPWm3oQdjTUlov1zakaxcW6j98uEv0qIpZppeTa81LR9HdX1+qIVhioN65djtedjrZrCEmAWejlcSfcrq/SubW0p1vk0/AeR69e250eH4htq2rzJRX448xbRiXEqKN3RA2rApYQ6Ch8hH6NpceF/kk/R8MteYJXSSy/G/W3njuZKGUU8If5qjmLioF6jW7u7sSpvZINHEadWMsgqXY01QStFy2fTqFQnuky8KMGN4irYnGS8MvOzDl1Sr3N8DIOz2r9cimFZXcZKMukea6DnStORkeg7ELJMkBH31e70mhxOERKzZArrYseGefavDlG/NqXOGsGwuWmtgP1uOj1rs00PwPmXg0uckPVUSMzr3qkzJPjtUiiB9edqV+7S2sqkgDouhlMVOXYLKjoHW7Anjcu7RPonLfS+uCXN/vLvigMigTJR+ZZ3wdRvBqCGKgf9gXQhaxoEnVVUCxqLWkEmsakVOTRC1B0T97KmRIPlqKVqrrBC5OxqpHv0C/6CqwKHtCs77mlTu2KEYTFZlDsq120watns2F0y63mwpMIxYwOqqNdasfyEa0ucd9Xbxg93IRmn/j7D3Me7E1QOlwbPR2bfaivTnNJ3fpizR8KMeyZI18Hg6RCvkVvnmsr9Dsui9LiOEA85ZTJaRzu1MIA7czzqntOECJqwrWrm8Zw0wQWW9SPo06krK/LXVIe9pLL2o7LYAGuZx0POCNnXPv6Tb62pMYR+BLoeVbQV3ugE8haL2pOlf1hCUo06PjoTLVHx/JFW0qDaq+BEEFZNZh63Ys+AC3uUqgddHEeqJIrqCNuxFeQ7XeXKqbnLCPp1i7y0hQw1a1qzkQb7jf+aN2c1CZmaXxU90ZdWAvLs9ZKLrPWdYARU9RjVE/YiM39WFFskFRl2LFeY1pq7FTOLJQumDnJlS9um9hLQAF1m2cOTFUHs3Kv9SVP3x0ScTrcOVIvLPs9P+oU3c6hsZ72icl0GBVnxjKqTxcBt2wA67nCihrqRSNuwzJ/WBwkvbFbOdytdlELwINgFVs/UlXT02ots9xZ1cb7NcoFsLs2GixPNdSKZBroR3JAk9gVgRG1K8pOWzXHiFu4RJHrblbDSVh39aJyayS7PnLsMXcsDNGY4jaNonGdryYWaIHB2JhFdVfTb2Xl4BgY82/6R7PpzieqZ0xRpYwCwOVScbBG/ciYWAvy1BEjCidp3pi4B2ILDGa7Cy91EQ5ayZqW4tP5dhDVzSX0qv4AEWyPirzpWHGzthQ6Y623KwflsDkuXwvyuruPro7S1HjA5cthNzRu17A89oBn7UuBMDgczqArIF6AtTT2u87gqsnSYNtxS6ur2L5K/P52as64vRRMqzxKk9okbEwrN2GR8A1/gmHU6ARrE0r6VOorV0VPDsvOZcxNkYNWFQv0p0NxGa5N39lOLmor8ZdSr9/yZoddI7HVmTQyrvK4ghKmuB7ZY+NKAj/VGm/0DjUnQEk8GE7PRcJZ0aAKZeSt++4x1EtlIH5tMSfpjOU6WDhns9VEf+W02raawFjUkTEpVSpH1NjA5EBvTANgjfQO0N4ICxFzBgp9WHfb3AtjsCVgw7USKg8rfYW5hIHW2G3dXVeRyjFAfTbp2fOkWO2vlMK83DgsQP1sLo49c9sWVJIPmzRDZ9yejTBvVxpXR40m5kWOMcZaPcyOWxkwD+kAwFNrumXQ3nsVplWuJ9doPtJrCAlkM2ZJv/Yb65OBITRlj5lqYMUT/8R04hXEmE/19cqcXyznEer8aB03PLPB7culIAFdVmzFXk29FLaGD5osb5wQ2MNLOFwmRYQ1t/EmYJvLlV5UtfXyYZ0gByl1quqIaK7BBbgDWJ696saYIcM1JHe5qzoWGIKh1FjuYYr2RXUZ/e2maHEo2z1QT12MhcNWlrvx4XCBNyiNvWuM2/ujVTSrc+K0KIgYdNHU645r2pdxYCRHfdcQB2ay27lcvzepeyBlGkFZZPdzOxIRXSk344aZjeMQtDOY8hZPWt3Dasc7raYfKa3ahN+HqrqPquJCVebicTUloOIWWkQsSdDm2uVQ6FqzsVo/+atedwemcbcd1NELhLhtbhHODfysMuavu/C1jT1mh+CSz9x4u9LG+yDQW8gvDnMbKG9Q3sXC0oMJKxdDB8tURlxzAjz15VgLUO/qh+MZ+VOnH2rtSr80dvUyOr/aPNgUoS0eduubUQYjTFZ7UkvhlvKcrykm2Xt5G1aB5LVuoqDB4GGO73I78ndOqTyUzZ40Apu1OW0aZ5PnfFu9Rc2C1pt4TV4kGZAAidYplbf6Nqqf91W7W++PB+VCb3Ir91TC0wpFW9kpylXcGttDmEo0dEtK4vpYKlq1ThdHNZhjvYI5CpEj6I04Ddtczn2ipo3WcoS74syQOy36tUV9dG3uzDLX6sBn9zAHsl06eLeRv0Is3EUVB/3z9dPQEIrGMtQvwAkX4wCzfyJ1C3bgfiGHIAv7vYWIhscsaW24qhHoVQl0If98AFyYqdIMY/zXS+tyiW2Q8IWWNduFuxCMjkBfRhvpeBkbMsrQ2qxl9Vv9pL+Uqn5c9ieCSOhhqbiJc8YMg7M0dauXHl/yg+IU/Q4krMKBCX1TJl7pYPFKezHdtLhFZZFsQ1OqTubJ7TzD3PbmfIPR7eqc1yti51R0RIRDpVbRnMIlMJN6pO4bNUSjprHpdflmUNw4MxHA7ZWumiqVm3VH66MfZMudTst+ozJQG8DOliC9lu2LsQA+NdrC3lxre2EYaGeUT4YfFsBuFwAbF0ndgxlQ6axUl9NGXVlfxj2w78UgGMlV5Eno+a5haFU7CJfIKYPs2nXiIuhmsRjV1+NjAOLCBvW1pMdXDXj1FnR3z3F9s89HYFeavcFNFdXFyu3MjosxaFeYRcNzus6DRSEJZxM1ndntgHTZHFdK7T2/Uzvr9bY5FhaLcThEP6KG989Su2V5Ha098xJcLscQLN9hczSalsJEFTq60ODnOyvnoeD1OurtcmDcgHhWxjgM161ObyzsmscO8O4dWs1xp7fZhSekmPGm49vTWVgAhchzR74Q9xTMfF579ejkpEkPlXNy7cfnpO3bo0u9as+MFW5RdQjW06rkr0rldcBvJhN3pPZDZ9XkhAW/w5WXC1vfsKfi6lYY1YytO1o0TufDZXaxgGp7vBbzO5OoJTt3F6yRa6HCu5YqolZH6FS7ymgRX0CN2WxttRZpU6e96G72vH07SItSHIxOY2McVbaty9TjF6Z2EG2pJugLwqvHBVUHQ9mv9PeTorpFz9gFuZQRhwvQJo6BvQxH7sLjkvG+EqiN0BPQr6lt6yTsi57FJtAeZ0YBKC6Ru2tzxma6BkV/Dlo2548yXJ3pQbsvTE/bzrHg7drDkTo0lGLZCM3TzD2MmiBUTvGtzSlJcVbrER8Uun2MEmY93dBNvRHLIIebRht9dsduqzOPkL+iVTbRA1GNSqBz2LOWNx3OV1tHrcVmWChuImR35UrvWAETTqtKS9eaiDX52G6eL7P2glg654JanziWdeZGuH/m2r6uJFzDOdWR6pOmcery3gUhYdSAoJKwImyKxrbtFEpDJQLOgwiBzv+yesIld5zWFL3GsXtqr46jkMKhWjVW84a1G5cCtY8xhu4SJOPiejZOIKfasKNH/KheOJtgwkp32JqqXWcEYuZ8u4ol57bfb5yqtnWdio0GTLExNc+ccVm7Ipj3yhyYVnu4rwFvbUV2c8t73nDKj3YWbM1U3gF8mqpMfEKLynnok8wB0PBqm/WQ4MOgoPZaTXe3vZXQLpnNT+gVSaYX1DMWEXqY+hZ6cuHvWz24TACb+0EjqMlrlMXjg1Na6/7EA3rph8VAX4cu3+4eO+uyo8T+ZNPFg6dlad3hw6K+3J/18qW/P6D15wCHdw8b1C1ws/VuZO/FRs3eSLExIr+UznLUO0XHwdipaWtdQE/IZmMgPzpKoKy4gN/bWq9+OExAHz/xNYtI43LUbG7LVk+eTmO0HrrAi0pBvRA2MTOvNgLEHV1lt3S+gGFm7xWUGnqx3hu2ZsekwCsWkfjlc6RV1WbUra56sjjWD+h1HDnG0W61h6HmatfRsDvX1vJtpvY5MDerxfS566hcrF91U1lwfqFevXVBi+z2yh7I724Js4eN5n5/K09naw9zy9B/gntSR7+CsdyJrtXmpRbY2WWwOXr1QjkJ9WK1NBc2q0pnPQUzDWczisQcMrfB1N5NgpmMkZkFGOBV7QQIvjltIzVo7uxTfJrMMF//qDjGwZmi7lWW5u7OM0VtIo3EtiVP5qHaXQ4K6CdzloCrRsQns4YiDA92iuerpu/vRKWetBv+VnOio3tqtce9COjCC1zrFMancR8j2S0QATxah9TcHvWC7Vi6urvL7YwxRvigPSZ1Yw79sna+jpo1xPrDtIaaDwm2BdU9b6hrZbq4HGognXqDlZ2obrEy2Z+7ZiXNNsBPJ/GqyUmqoAWH+I5us5YbJQcd4+CquFwuGzU0ejCL7Gb0lwMPXnOd18BGXRe4FMumpVND2Uhb5MybY2uG4VKtYRyqMm/F1Wtku6DcNpJD43RtB10+AHprjZcN1C5mvXnk2Pppg1bCDGSUMUk0v9v0l9akr2JqKDxXPRU644LeEJPCWkWJdtX6UVi3rhteEtWWUjNGTSndS5AT/ZY7bJccpbdwQf262eNg6o1DUHWnqr/i4B2zpYo2JOaSjWuRnWpbwLHs42h/9cOtRewb9La4RedqcBGxYPrIYkr7IXL6yjDUm7cGgUEPLG8VEONozkdFEAfmpbAZmFx7Ujq1ds3GjrN3jRTR1Gu4D1TUHDrnmdPu9LhJtVcrOSFogTN+JVxLg725TiQ81lBGm6QthLMRt28VQjcAnFjM/GEN9MX9ddleKuMQ1BKwPpqNLvFLYNTBTg6V03Z4qCtngPFkuEaDzuAj012EQng59vaVMB6AxdIQexe1itlfx5G4V4yVthiUZrNlHI1Ez0uGXMVFf/QkHrSm5LQRfrwhRJ7o9YbT0x61uOM09Ebc2ZEiYbRvbvqLy86TzCtC/xxLFcJj+EsY4oGFq7Pzeu2z1kCcXktNkdw1L6EGGFyKti7pq22DnJOrXXEneq7W7zbAghpvAm1Vazvz6ba/Jpu1KCqLhsvpy7pjiLYsqnprXbv1uzupSnTGqLXt9IeXYV/oD4zImNQDdah3tj3Q3TRPskHync7t4bUfnNRaPUYMrUZoIpoyWMH9fQz2QiT2g7Naq8Wl1TicYQ5RbewCGwSGYIX17tWZVkOQRcuoAGbAusZjlL6iXVXQprbDNtfl7abR96yjuzskgeoCtACbW3wSVDytJvRAq5Uumg32Xt04j9XppNyfRLplbs62VLm2BhNvEspOa9bjFuq1oJt6u3zALIGt1NhiNlJSj+d9NRwTs6CoDlVzPeLSTPZICtrX86m7P3RVSRWT2WGBZ4m4vbDabXdOsb7dVe1jHWiyOQYMm8nXrjbktFXdKyD38CN7BvCdH93IvlamvXLBU9tOXdl6qS952Ot1Fpej2uuKtrrQakpJB/s80OaYjFpenJTFuJLohl2s4clQDnQjQpWgv1a4WmFew4C5YYIsrGIot6bjuWFhp6E3bhOU96AH9TFHcoSn0kSh1Y7U1dFroQ/auOpdRUvqt6OgNuUruyYd0QzXUAII/fkR5Nh+vJqvYcdXrd6yQg7i+9t5SlQhcPJaL2pu/auFp7Kt3qG1al7RLV10loAX1+oC9ODm1hlVe/D6kbcZFWd6I7IIT3F0sHXHPgiwwgCg0mhvvRrSpd6bhDO3Io3Qc3UbAlZxRn2zK4Ry0QB12t1dN+IF6V9Df0zHE/d9q+LV6r1di5zPiLpSva8BNxk715agneJxadeCGbqpZK8WjRrnbzrzkeN3fL64mpd2+H1l/D4eCF+pJSNXRhW3Fqm6BGCZRNZFt5S5O55KfK1V9Td75Gk24OdmlGbd66kjUIu8baebFJBja7Mh5oYfurOz1uHnIzxzAE9f0WU16+8Hm0JvysWF0Wo0XlUL03KnngD4tY3q13vIIueVaCx6bntSb4tedvpENNcIF6ug1qxVKbgAYDtHEshrhHqrhu92dYR6/7Sq9lR11Z4etgTS40IwRkjXGh1M4z5pN9SDxE3MYbUEnk/Ofmcq3Lbo1+VUc9Hmy8WC6pbGyXIywrP3yhGWXa8pchnPD8WeFba7+Fetp/GAKdwan4zJk7jEptpVZPJbhL85KOZdf9VDp1UPnxOA+tuTWhEYrTQbl2djPN5ZwfSMa3U7H9oH5wS0tloiwM4bvSVHsI2wDhRu5SbyhaI9IlPiexrFC2Z+tgZjRE2LQANjLlMtslb535S11er3xdJM2JDKAs0N1+wTnWJb8JEKuetN6bYPhxYq/0cnjkg1iWPUPvUmgawiLg0bGyF2Nb6UyOUUfg4mTKPVsZ8Xutk3N8ln2jJmLd6QQ1oreVoBivUL4lVqIr2urvXI7RvzwqwUAu9Z1UmWf7vT59WjCrtVJwdnHTyNNCfXMUVe1LjCCFSCcnSMfMFYKXPd29+0UptLfNC+WzVul8jI6f5bajd83fsT7j0jVzKkYWPnV5d+BxAX/zPXZX/VvngbXf/RYkp/UJbotThQrvqP+EuL/8gvxX+cKta76qy+ylzlqjaVig9Vm0ROfK3axL3ZuI+rb6W87M90Ek4ZbHbH83wX7rajtXn/9QmW93saO6y9RAC6nJ7PMYXo6HLePW44gPAY9/ODAb7sm8yGxo2+PB3FuW3L1YAqk/8IAo2OZ/V43EV3ZCG/lRcIDvqmCbsjWI9Op0WQ/khvecVDmIAj/2X4gJD5G9gA0N1djsH0j7aB4gWsOZz+EeEr3Hv8Ok7Xo/Pi+ri8n48thc/AlhxxFl+RINujfw8SFL7D/X8REhS/x+tBkeR60/EJuO8LnjxiwTsOnd/xHO9/pPOnvVHIf98vFEfR4KPYucgJrNQk4+gi/634pm7hu1J8RfGjdqj0qUw9x8cHD2z8+0w9L3tf6LsoGz+Rvqe3xTm3ShgNclfua8QBW+JHU77C/Sj7lz+T8tky31O+2rT+HVTPC4+1N0VReFNw9teSvMK/wP4XSeZP0cf+Lq/5cEIWf5CQUyH6DwiZPAoAHMW5G/a7xfZ8yr25iT/cMVdQHu0PocDlMe/lflH+w/sxuElWcMfb7FP+ASq/1o39Baj8DmX/IbZ8vDL/S5CAL/wFJPjz2bjSt4L8+EkpWdEHnxjhz0CoH6jh/N9QmfiE4XntBigr6M2qIwxjTRj1uqK3KUnY1cgy1NBuqzdroUnj3u0SJNxiVG1xgbG7NsSJOIll0Y7la7AJrvYSuyOVkskmWLzpJhWxPq/37mcqn/UFiPM162nfAWPFOpmxzni0P6JF+xuZpJ8A7cGC97I+BKQ3Bukf4NO+Osu0F4ZLezXC3WLWj5X2kHGN+/pIPwTWn5X2l8W+DbTnK+3LsKLdjQc87U7MvY5Zf4AO7aHrpT04lmra0dmn/Q/yY9arJO3HKNOeAg/wex5/SPc8X42dxJadxBJswxLtZIU9I973waC9PVx/lfWjp/2AZOwv0fDJPbQLHu1pxPp+JFl/yJj2mmS9KFgPjbdjL+sEnfa/sR/Ht3wvAyfrz/J8nfbHpH1xHdpdHe5n+8C7Busa59HxKr+mhzHtkUI7ybI1Bcn3x6zfBO11SntuABzpOgYpDNPvlt1sjhXtmbWS03eqtL8Mw8n8+MM6TN/ueDGIbPwuP4i/000uoj0tZDfrsKhGjI6AXs+5/hpi1islhw+0j5fotN/u95sx66/DcID1I73jRB6n7r3FHq+7Pr2e9v16wAe232kvnoD1k37a//yY9cQJH/b7ef8fxqyrYZLDu/R9tH/WinaLXGX9X10/5PN4ZNMeI2/Hb3p75PACOw4Crvyd/h7mHSeWGS/5bke8FXZ5Y9xDJt3w8r+nXwUcxMwogP0OM8lWyi0j0m2MdTh5+T2lDLet5rhU+nt6H/udvZd1/aYdmPLj7HnGhdMucazHpGMwDA6ycYbBtMsR7SRGu8a9G7NuPyHFCk98HHeklzHtxkY7oYm0609Mu9e9HdMucfzDOw2bdg3zaKeZdF/u36HSbj8rum6VdjxPOyvTHpyym7s/f51275Fplz3ayRgwOc4w+OE67VUqO2/GFB9ot7iQdRK65b8HqFl4ghFQ8CAPE97NpMvrOKN0yl1oN2I2pjDIjzNK5/J757Auce/GWT9zL49DMuU4DMdk2h1OvnfMWtGuYyvsNnQjuMxwvP1IO3fa+pBuyX9FInBUsxLtrEujR/uwBqyjNRsLrIvafcy4tUm72ams23Sq6dHudo6v3rvfZZ2uaTe7tNsdn8HXeJT4TtZFMOPwdB3ZOHLi53HWaZPin0o7Za4ofaU8hN2fSYRsTDTIdP6Hv/P3hVRydmjXv5BjHcPtNhtnXfIoPDymEVF4sI6ZKtPKMg2JdsUSH6+r8cs461YV0H2kY9ZhzPBe9vmzNVVY/43ig/S0fsAH2ic41Vwlm2kI2bhz77bFuhs+dPdbJQxv7+MMTykdevk5ZYfBjNLmnaZt2tN2xTof0i5mK9YxjfJTO+sbTjufyVQGsTFZ9/PYe3hnmNBOiHTOMKbdUhk+wfjT+QXvph3eJDuzxAZU2wtpl/gBpQv2PffxvX821XwMm38cr6SXMevWSTVXKtMEitcw7uTXRcaUX1CtfEW7yzINcEVpi41D+d5pN3xcRzam68yPzQfLg+EpTzv7Efg8w+u3obs7vnIZHBPaFzvluzK1cHPj1d16S2mAWlDZmPbtzo8zbZ5a/gPGzzg6lh/XNZDvdJTup0O7odJ+0zLt3Jgbd55pla4jG9N15sdMp1vRdbHusSs5g88zvD6k+95f08478gvGMo3uTjlU86J9OtnYz7TtHOWYj+MUsg/jRwpfxS6laMoFKYWzdeH4scc1QO5Gx3QdTCPIxmJGSf7DOu5jw3wZMwqnnIfO36F9hkOqyT7A67M5Z0I1SeHeP5Rqn6nEAzsv7UhPNcuHMbVaqKbB+hCbHPOjpJzOyjSze+/c1H/H+vmC1kX7+9J9yY8pR6BWyV3yJqz36EPfy1zvXOZTon0yqVWTaSK5ccZB2nlNy6aa+0B6hssbjhmDdon2MEhk9J3Yv4BrUmvJuHNN2idecgzau3fJpHq6lzbr55ppryallYD2N7Zoz94B67VKe5yusr1+uM56mlKtIt33gcg0m6frHO0Pm9P+7mOmPVELmPWvj6nGKbkv46xHMusNTXsDUxo3WP/6HJzuPaNpP91Azvq6Giq1BldxnuuCNsu9jB+lAfuehPr9hHsf+/uYSgPq9xlQv4wlZDj3Mma0MmDWQep58B+tVthj5g9kWnecSXf/iVZoj9i7lZPhPu3RHFL4etwjDHOw+Q0kzoBxHCmzkZa0k/Qy84dQyUMlUH7MOlwbrEsw9eIzTx3FMLoT4t0nYOahyNsMysnrmEqCW6ZjppyfYmIaabjbgav4Lm1SCqX6OH/HsM7LmEkb6kGk3CsPmxdYvfP2cqm310vQq2f/Al2d+lYIV6LfQLEu5Bh1sHGeIsEOvWV+kNTfAfOxDtUW3UvrZUw7h2eci/4rZNwqfh4znBqwLuR0v0LxwU7PjzO9O3jcL4Pup7F6GbOu6RRH4nSfOtRvQPHYN6mNbkt37tWhOiNwr7tPgXKBVZLXsLK15sePGhdbq0A7vgv0Gx7GVOPiqe0kOY/RCuYXyI2foxdMI/RYFIXSZUD/Hdy92UzSUxqgujbQ3SAd+/R6pmN3GLdKv9EfUO2C0dSA0pT9+baOz/zHHcZXBIoTkfPYPf1G/cMS9ZEkiOs0ikk1KjumNjjVtDwaAcmPWTd16tNZpr4Gh0pw2Ac5vy4yptKNdqcXKV9j3eEFygdvFKc5J/MZmNR2Zeswqc+TrTM/Zp3Gw/y3JpSeGSx4Owebt1pXFoVII1YfvX+AUzTybGY+WQqnxM72L6BwGbyMPSZlU3qi+8h4UBr9pHInyfaB+nzth27t7FlTzPaaaV0+fY+RRraB30rZmp/HJsU1hnMpjSWsizzVJNNIOfoJM5xd5a+zyDN7/gEmvYXm3L9L5e+WRqr9Ay1IFBdiun66zpBF5DP8onEQeh/rQk+jxvkx/S5K+wKL5FNZDTjdyb4LcD7jiQ6NMtqMxxh2/nmJ/gt7yTSnkGqSHt0HoL3FfY0utULu3xBSDddkcof+q7K4AcX3t1bGr+VVTKdCfGEy4yGW0RGpPKC0msrczIeT4kCKg0lAeXPIPVhT93czS5SnlieMO3TMrqcy9G5d0Iio36F40qG8jMW+cuMs9kXfmXj5NTK5zb6Bv8s89o0dinsDqtN1RKovcNSPx9+9Hx6fXwfAkXobmI7a4Z7WJVDZL1DdhGdjSuu58ad7DDiaXSLc5a9Hs3UGMYUFs5zfjJm+wqxpi38c26/jLDZDPUGpz/q+jjfjp3UxX/zTOvPjT4frjfIesBUYHx8wWqHXqVWfUJmZfm9iZ3LFpPqZTWMNJosbJ8wzQ3lT5vO2WdZRksLdzt5J1nKjmQXJHaZm8vROOldu3H6eg/LrbA47WzeZg/pOc99xY++kMo7aNjb1LKnU5rH5bA6DZi+k74yoVy6hftmI6i7JXQar1A9vU3majWmMO8swy+L57B2Uh+TG4cOc7n1NzHNA5SeN8xlMB1wxv7N4n4P5t9m6U9ymuu+N6sbEgmewovKfxh8Z/D3mjaN8qpPFE+3Mv+3J+TGsk6NjnuEdnYP5rl+uP+Ht75BNdKO2YYJZenT9NBvGpvhvRkwPpbKYxgbszPYDOcnkFtVFqM1uULpIaZFDvZTOQXVc4CnpmKN6AcVVk9qdnZxnh/E69VF/YTZCfpzpM1ZeL2Y6OU/1lYcxXRfF6w7NoMwyKaneQr8j88DdxwT/sgzIzsuY0hH1zNnUX4IeIDJm7xeYrZLpfcsOw58nfdSSXsaZfspsoPBlj5/Hn+2dotkyRpYJRCHuUe8GcGXqY2NcmlIwd9fgbUahVINnkR+abZQf0zlYpIxGjW92FqF6HTOMZREoui7KbW26YybLILpjH91pZs1lkeGXcWbd3R44oO9RjhjS7/IoJqzuO22wiN/qHezusP0N/JBhwvjK3RakeMiyl9t5ORbe5S/LZTIYBGyWiyHnZSXQGZeNme8rpXUmoyUakcmNw2xMbQ7ezWQyGQuPcwxuL3Msvcd35mR0Ol5lY5ZZTd/BYtX0ncyTvGI6OpuDRdCo/rBiURfBiR/G4j1zkfJJ6jOCMcMm+r57fpv3+A6WBZmNaU4ZG8v3NVmUSgb5NWY5GFReM2xlYxpVpD4Uw6J7yexRj90vUZ8Ux3xaThqh4dzMp8X4e2ZfUhnl3f2ELK6s/xgOZtfJmpgu5v0m/hSL4oUXZ+unuEZ5u+Qy/E5/l+66HJO9YabLUd8p9X17FL8Duk+dex4djQoynxv1F3NZ5MS4+5+o/1i461FkDpbfxfJgcuPM5ybm5fl9bPIvY/btbF6KD8xepX4HJhW4R/yjNOYzXwPL8M2NGX77Vl6eyzazKZh/I2GRQ6ZfBlTnZtHQLB+I5gHlx+y77Vtenj/s8fP4c20u6S5d2NqZfs14LYuYUb5H9XMny0sMmSSmNE9jBlRSP4wfeet9DgpzlqOTH9M5RPtBUocCHVNJHbJ9zuIjlOYl5h92HyR1fsy+22Q2AJM7fF5S38eZpM5gQyW19Cipw99KUncYF82sM4flfBhU12I5IfdxnI0zDwTl3g9eDU9gHiAaO73nIPjU02OwaA6NpzNPls/OEWTnQ7gsCpRyG0pl2ZidI4juc6QeWvtBq+/cMmuDav/3Mw80ju2b7JwLn3sPO9PA25kHzGMRguTxfibt6PvwXwonmp3FucyLT7Mns3ez8xP3KBiVVh6NwnaSDJY6HbPIwiLbPxqbHtCsOItaaANqNVn3rF+2rvSd9N/OLdPDWbRWpxh8n4N+I82QpWfGcmMxw5HfAMttmutr85mNmWS+UcqTqWylfJ/qEqKd+VRpHluW0RDkeVPCMkvuGGvTuCXjTfYt07n0h3FuDpPqcUzvtITHNTKfFIx/A6gOqFf2DlXQhqX8ikGbu+W/6D4OmFYj0Mg+g6xATykwyApUE8xBdsA9QnaQPEI2G2eQdZZ28rgOT3hc5yD+nSBrU+5o3WMALM96yaBD9e/MemV+F+bbseWMMzzIROQU+TmCLBea6kAsPhff45jm4zibw2JxTWYxU+7qUe6c+463J6a81Pdl2KhbJ78EZyk0vEwrcxLGvynfpFTnsNMQ2bjDcFakXkeOnbVjuXMUcoLDPO+ZFThgMpFCP/O0s0hR5nm/4yzjqXQdS+r5Z+tkZ0F/I+h2qFSyb06Wo804gsniXCx2Gb+MH3NFWJYeT317CctzyI+z3PFFnucOKCdgO+gxGzzbQSeLe7K8GGbXey9jlidD+TaVyCynw2LxLmrv2DkNhPm5qUbhM/8CzcDMw+Y34Doe037vu5dpv3T36ImMbPfyYxbd9B9yLFlGOtu9h/FjhnomMW/53aPnW5N7pNjis90k16nvL2HnwnNjdkbasPO7C9b8Kv038fJejcQ2clnzTEo/nFbJOFweVp8eIcuoLvPSU+xOtRR2XWT+S/otucyITE7TSAbFzKX1cEIMxll2Ds3LTB7kK80wehiz+5OHjArBYWOWeUD3BrNqqHWX7tGSWuAsOsaynnJjlkFA5T6VRiGNHpvU8zagvtXs/ofrDstnphbqwzjLgkgjWhSfeJaVQ7XDh3GWwZd6/5lGSTmVSnXt/Dg75cCyPyh+Uw+hQTME8uMsr5VGxVId6uayTKR0v2Xml/61eBpdA3G4bYZYkAP/N1/o5aOqQgki/1QLThJeikIVxNeaUDwnfFRRKOlz68A9FvcUi39SnImMmtPjAr5+evzxMlEfXtRJ/sF6PsVPrcuofLcGL1Zn09ajYIX/7m7/lipt3CM1ip9eo+1za+0+keOf1dp9S435Wo1vCvL+slKpwo+WShWFn1Fi66WqlVKQHpBLEZVfW9Kq8ELranI5Tv+DSK+ssTr2+Ah/hfiXvt5dJi+o97uU4X5X6vOxPPdHCexiVoiM1SZ7U7lVKn0T5FcmIX1UQW7ltbqu3oKxd5leXqvqsrJlQbxeAFyP4p/z73G6A41x9kPGmd3LGV4zZehA6Vz+b2L6Cic/a2Eyn+1gXhN7s6mlD9vU/6qCvL815y/8cKncf1ph8x/teOG1VK4+H22xjh+n7vfHHeD+v0QPk0uFB4IU+VcuWxJeqbFQ+Ki9+ZRaud+jRv5PqPHfUv2+IPwgYX9u9Xu2zD8qJvpS4jOHBrQ6K9wna/+RjZw6FQAckd2y8p8PLFfIlQVdbLBN53qBncA3p2CEtUHLl22AJUpP37BK6AdWvBefK97LMvetVHqhaPmNZSUpH7UprxVeU9O3PR0dgzn8YQHx/ksM39emBJKofBP5ly36tdZv4VOcUf8a7ln4Ue75Tw3if4YErz6qF6xAYtl/lziC3XYLzHU0ZrdzP4Vosq5LjKsJhXdcTXlDMUx3+fnAUn4FsP5gn/6wv9g7UBQ+DG9e/R2/g9RtT4/XRTD9X+3yl+TuH+z2K8iZb+KVf78TsfJHmbCFV7/E70G5vCJL3x5rzEvcK7BE6ZvA5f6TfqVTp1D6c+A9V6B/Xyf+Pz9SFf4vVX+3qvPzuCIn7nZ+GvXkY7Nd202qrchdFK/wlNjYBkljU4qHcfHm+iu5Iab3WYvX6vMkFy0ZwDuH62Dr7MeCVLKW5sVuW+EUc9GrmgTPyXh/UA2x8jw36rdOQ19Ogkp3PdR5eDe/n1S6cVMv0WtScVjpJoNENoJNdw7XVlZ1sscMgo5wXk8qzq6xjK7pPXBvnIskbuTreNN5nxffvkckx5vSZcjGOh9Perc1wGE92XQvY6G1spbkvfCO8iUQhgBDrmRtW/GgJyfjSpkbtsOzszR5rJKdRi2jsOHbsksqaZPf9izHotGv8eMKXdt2vw42xXvO/z3y+VyVX7CX5gnreZJzz755c9ua7SytW8PwSG2P7O825jEH5Lw8+Q2zKZPg5rQjcmYCfovT+8gzMcmSXVpS7nry5j0Jncdmfzuktu1KJufOkkFsx/i8dyG/kdon7LfOhX4T9/qbmVhmBN9mn0jtP/K3lV0j8yzYOlPYOnpEMjnSbyNzyc7i5TfJidPffAPPh6d/wzpvrp59VzonXtfTNWE2sE3ypLPf6DPptzmGJ5LaBMbLezgrm8ejc5LatHgGOr2O3+Z3MIrO2+0IK1WRerO0liFc88j5GWfZge8G3GljFsWA5N8BbnGkPiXMB98Kz4akrhLQcYLzOm2MxHfw/MUJ88ExgwUr9jtkjxEvBxJW2oPvwCg5zhPbCZ51gGcXOM/qhtlaGE0nZ3CWHmb3wpoGuEYZ84Gy9yYqVu/L4R5+Z+eG2Q1YVwIzMAhOJauY7AuZbyCSGs9kf0MGA6wQ9wiDZZCDgXezF3kYWGIOBpjtk4dB7Op3GGB20CMMLCkHA3hWzcMgTmGdwgCejR5gsFxlMMjem8HA5kgta8O6EbxEeJE1mDH5nqRDat/AOkX8HoA1doqQ8MwmPCtgRhE8KxBc9RF/bXofrtmW4Xrk+mZC8Ipc927keoy1c1QJc4bpb7Gt4zP0bzLPipwVf3nP0mPzxA55Br8hwM4agn2/nrx5T3Kfh/yNOB47fnija09wHoAj+QY3rRNBaOgPcQJrKAGMkN/YiSkAjSG9JKRK5HKFdQAQzjE5N21grQ5Kq0kQOylNJ4gnWMPLNgJC05gfiplD9gL2L7HwdCa8W5VTfkLOzuGZCLiGOGQBzZM5RTwrCziQYGY+zHkjZ7nhveS5GPNQzRjP18G19ARDYqcwNQKswQ5rwKwkWA/JDgI8NlZ0PcBX/CCmdCMjDwdYRHab8hWspe6TM0EpffgBwDTkyLxtkqHMY+4rPCuQ7KEk4CjPw2p0UXp+HTM5Sd1mEeuRpN+DNLDCTgZ2Sj8m8NBOlPJ6wkPwXRntObjXhkXgBHsbYfcPO6M9T8ZzZOn3Ik2YkUM6osC8MZ6DV3l4N6UfE8+cSSkccc1BhDw5pXlyMgGzsWLy7ALnGUiAQ/RZnAf3FGmvQ9aIeED4ATlvb0Z4Zs/HZwnNW4BXZP8kUnMtschZTiKnYG488erQNWMmFuw1mTfFmSC+0zXgXmJhDWKUtaS6H1znKR/iUYYiLHwiwzDjKsAsW3w2IZnopAYCmZcjWVewnyld28gDCL1adM2AN5GTworDE7SOr2I9BVwbqUMF302vwXoTpCX2PSGHJ3TJ96AsATxJzw+SNcG3eMDfybM8qajoq5gzSmSqk3iCje9E/tEm59JjzIQk30Pq9naSFG9wXg9pK6L7R07p4FlUm84LcMEaEbKVPotn74EuyLMiyWBcBniGFObFbDELz8yJlF+gXEc+m85LeMEK5T3gO8l2S9dK5fDzM1T3wN+w3oLkpjKf8iVyX0zlfKpnLNWUH/uWQPcc6Qj7KcBaUVcAnYKcGKFrQV0V17IMGFxADoDMIfiVu5+sEzP6LKSXmPJKHmt6YSYchRlmx90oT8V6BVj9Mbbu8AY8J+/lSe2apIO1DuxU1wHcWqqpTIFn8b22Tzoroa6EmakcngtLeTipJZ3g6YUXHCHP2li76EZxD+t2xem1DmaqSi7mCpP3etjlJwF+lL63jVVLA1L34gWnlwM8YQO6DdPp8vSA9D7A87I3yndupP4c0hf5HuQzFtYcvLH9tpMwwlo8OK9LzpuCvF3S/cYKp8YqInI7ITCP8RusjP7tdE3Ay22sv44n9Yme8cA7UMbj2eE0azN55juEJ8NeBhLlScBHUbc1X6898Dq8Bnzcx8xPQiuPfDIhWZPIR9izHMhhxCl4FmUIyDeU44RGA6xxAXBQEf4oD0En6ZDsVcrrZCfBd+G8KE892HNVorwB9RCQMVjpGOe1Sbckh5xDJM9iBVqQbSZ9Fr4nUWPGczBPHWSQQOaNyRnb252X2YBfnSh7L/AJ+Fek+461NaNUfuJ7Cf/lUx6argH2R6bvTfD0C8mmToh+DHp6APOGFI6gywG/InLZDyldqFhPMpXj6W9Rqivj36pMTuEtOzLdMzzlxruZHUN0HGrneOm+JB61kVTBIfv+8A6eyNSliuujfyOPNfEsOPIPkKNYRzxEWcb2Huu/YJ0weB/JSpdSeUL2h8fqCun+dDBjFTskyYQfAByxx0NaGXfAof7pwr024b92SsMomwAWcA/yJA7P2FM4ol4hkvPACTyLeIF44tts3ggzdJH/Ao8kZ45h/VK2B6hDEV7hSXiOleqMeA1lKcmO9tFuWUiEN6TyBHUh+4a6bCoTPIGcbMR1UDwncphkCpNn8W/AV5PiuYX8SyRwAp0Fq47jqcU3dMkRPctHPrt6pj28BvqMDTTgvVxzSda5DXQbPtMl8CBPJrhJTrnkeUXKg8jeAQ24qc6VpDLNElP50rmRGmPw7pR/hXheGuQVZkkDTixIFrSU6iCE/mNSIxR4UIoTqGNYHOGpC6zhqeI6071rIz551O5AvulhjTU8AUD2HU8AAfzf8GP8nhXiC7uGNahkPM2JuEuy25FXpzyKI7RldKQU78m3gm6uvpEf+K2oT4E+uniWPfg9IHsA1vB/Ap0X9SJSD5TMG+MY7CrC33Be4COGifV2yLPIB0HvT+7yEnTIJYEx6vvIc6ncfpDD/LNsJnO18/b3gyxP9ZRH2Z/9lr5DFdJ9R/w0ZVLLwSCdCPG3zM5/1T868Rs9JuULicnsRzldi8mltILfFmJ92YTabqiLoo5Jv7uDOji1S4luJZDK2Ol7U13Kz/QyrD2Jde1sulfAU7BGb6YPAvxJDUm6zybuD9P50D4hvBSvEdrwvZjpg6CPxDncwnM7fPpetE8s5M3EPknx0iQ6OnkWaQVlGoEPeRZrNgpEbyZ6AdiHCepEnfT0sJE+h7wo1RtNusd4CsbEOsH4ramfy+jIqf2e0iDgdJzq1CbVfdWUBsm5KvQPeZSnh2iTS25GR2jXEZwFPQJle4D6KuFzj3zj2XZBubHCNUqO/mK7PPGqR9vlic892C6Ezz3wSFxzgLIkcSiPRL0A9cdXWwx5L6F1+dWOiwjPt4ntbCeZ3epbskN5DtFfEtRNmW1qoQ6Wfk+b1AnlUlgAf8CTrWBfp3BE/xfYVum+o/9PRN0XT10hHGE/cW4h1U9SmxdwM05tMS9JcTeU6ZoiG3Uw8l6L1NxxCe0QmxVP5BD7HK+hroF10dhzQAsCORsH32K3SV+oKNUhcE7UfUndZnjWJPW8XYNds7BeYZx+i0lqfTgp/0QbG/ED9a30W4i9N0DfR+aLgO/mUp3Iu5FKTT7Oj/BHPcdG3RjlUEL8fj7Cd0CfBRz3yakuWHdIf1PxTCv6X9C3IdE1oH8TbTji08j7JOGbUa6CXZu3X/CbEOfx9BW+O3j6jfgwhEf/7yD1rZK6s/C+xZs5Fjn/LNbFRF06GTyvm/qNADcN7I+TwoHYQ0vCu9AnIxF9J/WPJhnvalO/C64R1kb0MZTrCfoqOsyHKKYygswjkXpwRkdI/VdW6tPC68i30DeyXN1/06nvLPVBAQ2hrUT0wcf3+GHmb0t1NJtHukIb2r5f59+8h8/Nw79Zx+N70vWy7xlhLKEhkhOQeBZSL23xvNIwwV8dAf7FcNA/CiR+J/GRBsdY0sdTbOw1GiZ+VDSs+JobyZLWMfhKQoUsg0I5XHZnAg6WS5H7KRc+Yz/iC/4nzVxW4Ybi/pZ/IM2H5/EtZXnDJoVBOm96+SUy988z5bMg8z/Ilf/jZKKnxJYsOelN/stfS6z/Syj2pzjFfxhO8W9w6ncIT78klhRLn55YUvyBHMYPSiwpfgdan5VYUvyB3vD/5Yklxe9kfP0miSVF6c934JekZ0+e8v/+88Epe8o/zWN+fzRNUB6TYXhJenzFd46m/fmLik/pu2mu4cuLAIyjOHcbJZFnPPkJp+CKP5BN+Fufs/nlKPedtL5/iHL80zk6mS/+PZSDF33LJ2kJ4uN7xdI3USnd//shfPxp2PYD6ZifeJ5XEn4c3fL5y+/P3P+Ox+vTJNCPwF5AulKBKxbEQlGReOGnIfPvhL0/kEH7i/Lxc3aKJBUEXX5OwL+jZz4H/72UvuPqr0izZyU9/vzc+cdw2gJThNlZuiL/95CzID4dEeGE7JTsr8LI3zWjuCA8wqbE8d/E0gvh5g8Tlx7EFk/X93CuRvom5e95ov6fp07/QKrxR9l2f+x4Ep4TsN8cygYxxufuKb6B49Nr5A+CY+mdk+oJjqf5aEJ4EbKeyeg0zzjjX/EVARsscKou6czJ09ydFniW7z9vHEeNpxvGu/N5t3njWTojQ341RnfpEX893WO6WpZ6vrmFx9F+/u06nY4234TJt+N0Dzz+D9j2d5Hoz5Gl9B5Z8qUXlG8l6RUDlCzd/+dv+g94kb42/edvelZC65tUyKv/jwfH+cJbFvq5GPOJrrTvsFv6+uI3uZQDlij81tz2B9xwX4T39wmv9B0f4OfSzg94/r42/edv+n8xt5VfEOQ3rtzxW9fRKf3ogfHPraNT+uSaef+PKif98I7z3+Ebv2jHXx1VWeWk5nEXTE8nAN0zUvy/rOTxUjpJLghZzbo/q54kf5M/bIuKn0GUH00fPDs1/ufOPe5TCeSTi8k9hh3+rIzoT6qs8vts/qeKQ55lk/wemy/8rSKyPwklprfFOVsT/J0uqSDT4X1FOIhzg+f1LC+bPQNgSGTF74NtPPdTsgS+H5N/Hx4oSU81+6jU+V444fl+hZeecD1dwd8NIDS61Y2iRFfZCfmCezY7l17tfz7XGOB/Bin8Q0T7c0n1T7nVd0KbnPyEH/KTZ+g7cai/ioe8ILyf6LtB1+cHfjYm/jE9v0lpPe1H23tq6XNt5lwO6uONT5j9mGemgRTQ0/+T4aqOv5Dw3cuP734rvP7Iv94G//DvZnj+8d1vhdcf+dfbcMRW/fjju98K8uuKn5/m3zzNPz1NkvTe+mi+Z5iFx9FkMX3Mry0UTU7KXTMWx2lAHUZbZCuvZobByTpfeGdmzMh/zw6un+WPel8Nj6Qg5tOBccqXTMdnt9UoOknfjtOUuq0A16PBMP3r8a71aDOejD7QOioVnuvKKi+WUeGNT7vwUd6prJHHnwcDUBCdR3RDS9wfQemDgtzP0PuOaVnkv8lvYJjpoR8AxB8JBaym52BOkfaLNf5y1gj/U8Y9fcsay1K5qP011qjpvCgr/xrWeDqc/pFjnzKa72davIn1/VpG+C6yQ1WfZ83nj478TErSpDB+PcFTtsrud0/vZD+PP/BEzz/vffFx2//uhNebjP4PO4vDc59imeXN+NEx+CVm/A+3oflUjyHP/VZRlL8VN/t99pIae5+3mYXP2czfwEf741v0qT0CsoV+uQP+/+i8X+6A/y53gCRnnaA+Tw/+lFDdh/Nhpjn+AB/+3GgZW+gP+xL+kfH7PvFM47iiZHzPxPjtSJxIlunRvE5TAcN/j+yneMf/nkFJ+9/x6ESe/SjS5nn+mbY/29XHjnj8C5Uw8Udtnk+m/VdS/43TJD4yUP7RKCH+qFou/Sqt/G24+FUWfLVHvrPUgvDqNfqwBlFv9+d3reDCizz31GBEZEzwExqMvIXdDwSevvqLfPUX+eov8tVf5Ku/yFd/ka/+Il/9Rb76i3z1F/nqL/LVX+Srv8hXf5Gv/iJf/UW++ot89Rf56i/y1V/kq7/IV3+Rr/4iX/1FvvqLfPUX+X6c7i8ln39Ye5G363t3fugXdhcRMWb21VrkJ2PXL+ks8nZBP1D1+3PC0lzhMYIvye9J79OC0j9QcforKP0VlP4KSn8Fpb+C0l9B6a+g9FdQ+iso/RWU/gpKfwWlv4LSX0Hpr6D0V1D6Kyj9FZT+Ckp/BaW/gtJfQemvoPRXUPorKP0VlP5+lO43Dkp/vyDQLwlK87jor6D0T8aujwhKw/C4w73LrlWw5oa9m0zxjv8D7V1td5u4Ev41/ogPQryYj4njtDk3m+Y0aXf3Uw8xis0tRlyME3t//ZVA4k3CuLbBdIOT01oDCBjNPHpmGJERnK62nyInXP6BXeSPNNXdjuDNSNOACibkPyrZpRJzAlLBIvJctlMuePL+QfxIJt14LlqXdowx9mMvLAvnOAjQPC7JnCjC7+XdXrFfPmvoLJAgeJo7vij903PjJZMC0843fEbeYslPbRp6umXl8L3ZrayXjovfCyI4G8FphHGcflttp8in2uOK+fxt8f0NKvj+3Z79GV7jf+5mWyXt7PZXDsnuIUJBfN6utbTrN8ffMIU9fHl4/HrD7jjecT2G2AviZCyMa/JLTjVVRwbZMqWtsWZUBNW2VRYAsUX7KAuqbassANXuQeX8oHqBBYHQKnWvVs6vFi6Q/MJrvIl9L0DTzGpVIlxEjuuRIZpiH0dEFuCAaO96Ga980gLk6/vSi9FT6MypVt+JxxHZKw5i5jdA422meNor8YzYIeeKWB/JSKBo9obSAUn38X0nXHsv2VERmm+itfeGvqJ12jmVEhMO6ffVdkHdfey8r/XxIsKbMLn8O3Iu6dYf5OuPuY837g/Hj2lHcYR/In6jIw2Sn1tqd9evnu9XFPCGotgjLnnlewvaf4zp6RzW8tFr0iPRihcs7pPWDVSZJmSncJ31ErnslpgBk1OgbcXlG9wFZD5M0A/hFYqjHTmO9aIZzO0Z8EGdtd9zGJmYTLYsIIjJD3QYdC2yvnPvJF+Yg/6Cs0LBWQU3JYMVuJluZOZWsMbiUBH9zkz6U9QvEEaajejxStf2Kh2Cks4lKjegROWG3ZbK9X+9yo2J1aRzTde61Lkh6PzbbKrcfHlS7h6elZvZd2EICgp2Ixw+O9ECcVBuS29m2VRtU1AbmEhMtTWtmc2WmqE/djdUdv1fFMc7TosmXJDRIf0k9Rl71QdEK5Noi8si5DsxmczK9FCiQna+RzpH5idTtLKNW+UO8OvrmthLdQSyiz5+UCxhDJBL6Clr4ihe4gUOHH+WS6/LeJLvc4/ptJkYeTJMjDM4mxiXMYYMS7T7ix2fNP4uNm62pRZVyUQjXIcJHlHkkdtO6AafhDk9kUzKavKhB2+9+C9+BeR7cs60W9LKT0obu5JvUpUc4ZlErXgTzdGe/VjsEnM8qGUCNa7+a2Z3FUXOrrADo8y1VmlYFYahgjKBb9gfmHrFZtMrOKsFT/aC8fPs6bmPaKwZl4Zju29wPBngWOFJiRprfpx9ve2jNevQuLA1A9A3cwbqXg1+EHsW0zcjzUwC9JDm1HxnnV6D+b8NTqQg/14aPS5cEfP2SFR+Rbaq4Zb8m+5JZ32FxCUoni/TrQGOVo5f2SHhCXQrgIWDaaZE4XqvO/bVWXn+Lt1OrsdZhclGCKmdLJH/hmgOgR6MyL1Wdyjelrmg/2/QXJnjCCkuXisRWkRovfZwoLgvRG1EEPqkO8X1uMLIAIT82Frbnu/Ivbgogs2R3ktK4+5fMoEz/7lIyN2XNHnE5Cw9AwwxPLxNPrJ0i5l8yBbfeUH+ddZ1R0GjaZURaiL6G1AlDge01hAKCoPWAdU+lByPWia6bH5on+ieNkaybMoAVyJchSh6pecM5mjAq7PglWmWGRVPel0QsIxLAFZNbiAP/yXZgax1bGqgbfTjtL4Z/vQT4e+oOD97cpAZX+VBXcP+hm5VrK2FOB+I+cNK1jX5fvV4p/zxZfqfXoRJUCuzEGCKUb+U97cXJ1m9i5PMIU5SeBHDQA6PMZWOyKHd5zE6dt7szdSn1TyOOdGlISgTK8OsQGt6Yeyo3ES6nkN1RrdanUM1Mb04xDcsRJGkYlxPcd1C1OOEnrJwYvTu7MY/J2uFxARhhN0xmfOcdUjMlMZE483PgyKgfXxEFgm1xVFMWHYQ6ZMJWRlJaxxF610uVxtyuUQJ4ij0OB7sC7c59AlvnYl1w200sXLrBj8RwV0QEzgkV4ADYfSJG8XlwZLWBUnq7SqVddXyu5Xnuon1yHCwbFHFWsQqXTJbRE2jwirEmiRgaGPNroeB84+hmLxMxnAYtXyuU3s4bGI12cDJqpyslG/uDSXjDjFHQZJz7IyjQUtiuN1ytN5V89XFjx+Lo4npvWGtxLBWYlgrUVkrAVQgIqh8sQRsDULFykXBUftfum/t13ulgNQWlG7oEqVnhaTnV/oBZY+/u9KNSaPWCePqUuvch+rKGr91VqO7X3O6UUEJUXPdljXC3qXC4JAKI0oYUmES0tmYC+Og05gM0y/6oA+KybAiWt3ffZ/1A66qC0At/dJwdcBqxI7hCg5wpcAhxXRgWeMmHsoZzwJNJlv6la0+lUBTt+WMPIXWz/KN9ufeQ+sw4Kl1GKeNkqw+bYCqFHXgLf0d8OgYPDLsMlcyJKFdx4B0kZq/i9RXd7r0+uzRCKwxrRPpoAHK9F237HIX6R0IlWlnY4VDPWOrE6l+akVHjdlUsnsaf+7fUNB4LrPRxVweiYyJ4Orxjvz7KX0Ym8yE6XTVh0jZhGU6qukS+O/0Aareu8xenb1+qFCZv0hoKIQ6Mmcu41Vdl9ToNdVsw7AVphFb79+4/SteZrZf78oBz0SBJUHW9p7O6WJ68OEzxbxv4iq6D+0zCrDtMeih34jFWL8fJdPti1Oy3q2NrHsO9rEo2WXyJCxlkacp/i5skacsGmLb21tIPl3EqHwibY5RT11vfprDicU4z3R+Of+kk1XonjDt7M9cNo49y4weDwb7K9wAqCwozHCyAA8yOG2tvs0QEwW/H5vbr3RYoXITUeVyLtfeq2nFtELO5dS7h64KrfbrTZ+MKxXthuz1tDIS1Z7mxMj/wrO/0eDxH2L2N3r92qyus9TgQALAYaCRANS8dLOb+d8Q4/0PGng2TDPm2FLt/DMpuaKuSmadrkNQ4yIvi2qbcB/sbuaB7nbR2gpDTBMUuEF3r63/VW6gw4tzg95lBowhM0CU0Me3JnUHOvaBoFP3JuKOUEcM8guo8/T8qT+oo+afSpbXsiQvuO4UgswDoumOIcgeIEgx98bbHS4V2G/ePIriC5vgRFKPywtJurHn3oXb5hBuEyX0Otxue0o1Dw2b65aVnDqlkmb+5xrTMc3/6iWc/R8= \ No newline at end of file diff --git a/documentation/diagrams/DoSIntArchitecture.png b/documentation/diagrams/DoSIntArchitecture.png deleted file mode 100644 index 014d8c586..000000000 Binary files a/documentation/diagrams/DoSIntArchitecture.png and /dev/null differ diff --git a/documentation/diagrams/Infrastructure-Component.png b/documentation/diagrams/Infrastructure-Component.png deleted file mode 100644 index 2076169f6..000000000 Binary files a/documentation/diagrams/Infrastructure-Component.png and /dev/null differ diff --git a/documentation/diagrams/Infrastructure.drawio b/documentation/diagrams/Infrastructure.drawio deleted file mode 100644 index 68b70d834..000000000 --- a/documentation/diagrams/Infrastructure.drawio +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/documentation/disaster-recovery.md b/documentation/disaster-recovery.md new file mode 100644 index 000000000..3bf7ec749 --- /dev/null +++ b/documentation/disaster-recovery.md @@ -0,0 +1,82 @@ +# Disaster recovery + +## Table of contents + +- [Disaster recovery](#disaster-recovery) + - [Table of contents](#table-of-contents) + - [What is disaster recovery?](#what-is-disaster-recovery) + - [What may have happened?](#what-may-have-happened) + - [Disaster recovery plan](#disaster-recovery-plan) + - [Full application recovery](#full-application-recovery) + - [Prerequisites for full application recovery](#prerequisites-for-full-application-recovery) + - [Steps for full application recovery](#steps-for-full-application-recovery) + - [Partial application recovery](#partial-application-recovery) + - [Prerequisites for partial application recovery](#prerequisites-for-partial-application-recovery) + - [Steps for partial application recovery](#steps-for-partial-application-recovery) + - [Data recovery](#data-recovery) + - [Notes](#notes) + +## What is disaster recovery? + +Disaster recovery is the process of resuming normal operations after a disaster by regaining access to data, hardware, software, networking equipment, power, and connectivity. Disaster recovery is a subset of business continuity. + +## What may have happened? + +A major disaster may have occurred. This may include: + +- AWS Region being down +- AWS account being compromised and resources deleted + +## Disaster recovery plan + +### Full application recovery + +This is the process of recovering the application in a different AWS account and/or region to the one currently deployed. + +#### Prerequisites for full application recovery + +1. Texas sets up a new AWS account/region to the one currently used. +2. Terraform prerequisites are set up in the new AWS account/region. This includes: + - S3 bucket for Terraform state + - DynamoDB table for Terraform state locking +3. Manually set up ECR repositories in the new AWS management account/region. This is because ECR repositories haven't been set up in Terraform yet. + +#### Steps for full application recovery + +1. Run Terraform in the new AWS account/region. This will create all the resources needed to run the application. + 1. Run the Terraform deploying the shared resources first. + 2. Deploy the first blue/green environment. + 3. (Optional) Deploy the second blue/green environment. + 4. Deploy the blue/green link Terraform stack. +2. Give new API Key to Profile Manager/NHS.UK team. + +### Partial application recovery + +This is the process of recovering the application within the same AWS account and region as currently deployed. + +#### Prerequisites for partial application recovery + +1. (if needed) Terraform prerequisites are set up in the AWS account/region. This includes: + - S3 bucket for Terraform state + - DynamoDB table for Terraform state locking +2. (if needed) Manually set up ECR repositories in the AWS management account/region. This is because ECR repositories haven't been set up in Terraform yet. + +#### Steps for partial application recovery + +1. Run Terraform in the current. Terraform will create any non-existent resources needed to run the application. + 1. Run the Terraform deploying the shared resources first. + 2. Deploy the first blue/green environment. + 3. (Optional) Deploy the second blue/green environment. + 4. Deploy the blue/green link Terraform stack. +2. (if needed) Give new API Key to Profile Manager/NHS.UK team. + +### Data recovery + +DynamoDB data is backed up using point in time recovery. The data can be restored to any point in time within the last 35 days. +This is documented in confluence [here](https://nhsd-confluence.digital.nhs.uk/display/DI/How+to+restore+a+DynamoDB+table). + +## Notes + +This is a suggestion for a disaster recovery plan. It's not a complete plan and should be used as a starting point for a full disaster recovery plan. As well this plan hasn't been tested yet. + +It's likely that problems will be discovered when applying this plan. diff --git a/documentation/github-markdown.css b/documentation/github-markdown.css new file mode 100644 index 000000000..9e854f734 --- /dev/null +++ b/documentation/github-markdown.css @@ -0,0 +1,691 @@ +@font-face { + font-family: octicons-link; + src: url(data:font/woff;charset=utf-8;base64,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) format('woff'); +} + +html { + width: 880px; + padding: 45px; + margin-top: 25px; + margin-bottom: 125px; + border: 1px solid #ddd; + border-radius: 3px; + background-color: white; +} + +body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + line-height: 1.5; + color: #333; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +body .pl-c { + color: #969896; +} + +body .pl-c1, +body .pl-s .pl-v { + color: #0086b3; +} + +body .pl-e, +body .pl-en { + color: #795da3; +} + +body .pl-smi, +body .pl-s .pl-s1 { + color: #333; +} + +body .pl-ent { + color: #63a35c; +} + +body .pl-k { + color: #a71d5d; +} + +body .pl-s, +body .pl-pds, +body .pl-s .pl-pse .pl-s1, +body .pl-sr, +body .pl-sr .pl-cce, +body .pl-sr .pl-sre, +body .pl-sr .pl-sra { + color: #183691; +} + +body .pl-v { + color: #ed6a43; +} + +body .pl-id { + color: #b52a1d; +} + +body .pl-ii { + color: #f8f8f8; + background-color: #b52a1d; +} + +body .pl-sr .pl-cce { + font-weight: bold; + color: #63a35c; +} + +body .pl-ml { + color: #693a17; +} + +body .pl-mh, +body .pl-mh .pl-en, +body .pl-ms { + font-weight: bold; + color: #1d3e81; +} + +body .pl-mq { + color: #008080; +} + +body .pl-mi { + font-style: italic; + color: #333; +} + +body .pl-mb { + font-weight: bold; + color: #333; +} + +body .pl-md { + color: #bd2c00; + background-color: #ffecec; +} + +body .pl-mi1 { + color: #55a532; + background-color: #eaffea; +} + +body .pl-mdr { + font-weight: bold; + color: #795da3; +} + +body .pl-mo { + color: #1d3e81; +} + +body .octicon { + display: inline-block; + vertical-align: text-top; + fill: currentColor; +} + +body a { + background-color: transparent; + -webkit-text-decoration-skip: objects; +} + +body a:active, +body a:hover { + outline-width: 0; +} + +body strong { + font-weight: inherit; +} + +body strong { + font-weight: bolder; +} + +body h1 { + font-size: 2em; + margin: 0.67em 0; +} + +body img { + border-style: none; +} + +body svg:not(:root) { + overflow: hidden; +} + +body code, +body kbd, +body pre { + font-family: monospace, monospace; + font-size: 1em; +} + +body hr { + box-sizing: content-box; + height: 0; + overflow: visible; +} + +body input { + font: inherit; + margin: 0; +} + +body input { + overflow: visible; +} + +body [type="checkbox"] { + box-sizing: border-box; + padding: 0; +} + +body * { + box-sizing: border-box; +} + +body input { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +body a { + color: #4078c0; + text-decoration: none; +} + +body a:hover, +body a:active { + text-decoration: underline; +} + +body strong { + font-weight: 600; +} + +body hr { + height: 0; + margin: 15px 0; + overflow: hidden; + background: transparent; + border: 0; + border-bottom: 1px solid #ddd; +} + +body hr::before { + display: table; + content: ""; +} + +body hr::after { + display: table; + clear: both; + content: ""; +} + +body table { + border-spacing: 0; + border-collapse: collapse; +} + +body td, +body th { + padding: 0; +} + +body h1, +body h2, +body h3, +body h4, +body h5, +body h6 { + margin-top: 0; + margin-bottom: 0; +} + +body h1 { + font-size: 32px; + font-weight: 600; +} + +body h2 { + font-size: 24px; + font-weight: 600; +} + +body h3 { + font-size: 20px; + font-weight: 600; +} + +body h4 { + font-size: 16px; + font-weight: 600; +} + +body h5 { + font-size: 14px; + font-weight: 600; +} + +body h6 { + font-size: 12px; + font-weight: 600; +} + +body p { + margin-top: 0; + margin-bottom: 10px; +} + +body blockquote { + margin: 0; +} + +body ul, +body ol { + padding-left: 0; + margin-top: 0; + margin-bottom: 0; +} + +body ol ol, +body ul ol { + list-style-type: lower-roman; +} + +body ul ul ol, +body ul ol ol, +body ol ul ol, +body ol ol ol { + list-style-type: lower-alpha; +} + +body dd { + margin-left: 0; +} + +body code { + font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; + font-size: 12px; +} + +body pre { + margin-top: 0; + margin-bottom: 0; + font: 12px Consolas, "Liberation Mono", Menlo, Courier, monospace; +} + +body .octicon { + vertical-align: text-bottom; +} + +body input { + -webkit-font-feature-settings: "liga" 0; + font-feature-settings: "liga" 0; +} + +body::before { + display: table; + content: ""; +} + +body::after { + display: table; + clear: both; + content: ""; +} + +body>*:first-child { + margin-top: 0 !important; +} + +body>*:last-child { + margin-bottom: 0 !important; +} + +body a:not([href]) { + color: inherit; + text-decoration: none; +} + +body .anchor { + float: left; + padding-right: 4px; + margin-left: -20px; + line-height: 1; +} + +body .anchor:focus { + outline: none; +} + +body p, +body blockquote, +body ul, +body ol, +body dl, +body table, +body pre { + margin-top: 0; + margin-bottom: 16px; +} + +body hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + background-color: #e7e7e7; + border: 0; +} + +body blockquote { + padding: 0 1em; + color: #777; + border-left: 0.25em solid #ddd; +} + +body blockquote>:first-child { + margin-top: 0; +} + +body blockquote>:last-child { + margin-bottom: 0; +} + +body kbd { + display: inline-block; + padding: 3px 5px; + font-size: 11px; + line-height: 10px; + color: #555; + vertical-align: middle; + background-color: #fcfcfc; + border: solid 1px #ccc; + border-bottom-color: #bbb; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #bbb; +} + +body h1, +body h2, +body h3, +body h4, +body h5, +body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; +} + +body h1 .octicon-link, +body h2 .octicon-link, +body h3 .octicon-link, +body h4 .octicon-link, +body h5 .octicon-link, +body h6 .octicon-link { + color: #000; + vertical-align: middle; + visibility: hidden; +} + +body h1:hover .anchor, +body h2:hover .anchor, +body h3:hover .anchor, +body h4:hover .anchor, +body h5:hover .anchor, +body h6:hover .anchor { + text-decoration: none; +} + +body h1:hover .anchor .octicon-link, +body h2:hover .anchor .octicon-link, +body h3:hover .anchor .octicon-link, +body h4:hover .anchor .octicon-link, +body h5:hover .anchor .octicon-link, +body h6:hover .anchor .octicon-link { + visibility: visible; +} + +body h1 { + padding-bottom: 0.3em; + font-size: 2em; + border-bottom: 1px solid #eee; +} + +body h2 { + padding-bottom: 0.3em; + font-size: 1.5em; + border-bottom: 1px solid #eee; +} + +body h3 { + font-size: 1.25em; +} + +body h4 { + font-size: 1em; +} + +body h5 { + font-size: 0.875em; +} + +body h6 { + font-size: 0.85em; + color: #777; +} + +body ul, +body ol { + padding-left: 2em; +} + +body ul ul, +body ul ol, +body ol ol, +body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +body li>p { + margin-top: 16px; +} + +body li+li { + margin-top: 0.25em; +} + +body dl { + padding: 0; +} + +body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: bold; +} + +body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +body table { + display: block; + width: 100%; + overflow: auto; +} + +body table th { + font-weight: bold; +} + +body table th, +body table td { + padding: 6px 13px; + border: 1px solid #ddd; +} + +body table tr { + background-color: #fff; + border-top: 1px solid #ccc; +} + +body table tr:nth-child(2n) { + background-color: #f8f8f8; +} + +body img { + max-width: 100%; + box-sizing: content-box; + background-color: #fff; +} + +body code { + padding: 0; + padding-top: 0.2em; + padding-bottom: 0.2em; + margin: 0; + font-size: 85%; + background-color: rgba(0,0,0,0.04); + border-radius: 3px; +} + +body code::before, +body code::after { + letter-spacing: -0.2em; + content: "\00a0"; +} + +body pre { + word-wrap: normal; +} + +body pre>code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +body .highlight { + margin-bottom: 16px; +} + +body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +body .highlight pre, +body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f7f7f7; + border-radius: 3px; +} + +body pre code { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +body pre code::before, +body pre code::after { + content: normal; +} + +body .pl-0 { + padding-left: 0 !important; +} + +body .pl-1 { + padding-left: 3px !important; +} + +body .pl-2 { + padding-left: 6px !important; +} + +body .pl-3 { + padding-left: 12px !important; +} + +body .pl-4 { + padding-left: 24px !important; +} + +body .pl-5 { + padding-left: 36px !important; +} + +body .pl-6 { + padding-left: 48px !important; +} + +body .full-commit .btn-outline:not(:disabled):hover { + color: #4078c0; + border: 1px solid #4078c0; +} + +body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace; + line-height: 10px; + color: #555; + vertical-align: middle; + background-color: #fcfcfc; + border: solid 1px #ccc; + border-bottom-color: #bbb; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #bbb; +} + +body :checked+.radio-label { + position: relative; + z-index: 1; + border-color: #4078c0; +} + +body .task-list-item { + list-style-type: none; +} + +body .task-list-item+.task-list-item { + margin-top: 3px; +} + +body .task-list-item input { + margin: 0 0.2em 0.25em -1.6em; + vertical-align: middle; +} + +body hr { + border-bottom-color: #eee; +} diff --git a/infrastructure/.gitignore b/infrastructure/.gitignore index 05ee564c3..2d0a431c6 100644 --- a/infrastructure/.gitignore +++ b/infrastructure/.gitignore @@ -1,5 +1,5 @@ # ===================================================================================================================== -# SEE: https://github.com/github/gitignore/blob/master/Terraform.gitignore +# SEE: https://github.com/github/gitignore/blob/main/Terraform.gitignore # Local .terraform directories **/.terraform/* @@ -35,3 +35,10 @@ override.tf.json # Ignore CLI configuration files .terraformrc terraform.rc + +# For Checkov +.external_modules + +# Common +common-*.tf +!stacks/common/*.tf diff --git a/infrastructure/modules/s3/main.tf b/infrastructure/modules/s3/main.tf index 1431b98e7..298f8edbc 100644 --- a/infrastructure/modules/s3/main.tf +++ b/infrastructure/modules/s3/main.tf @@ -1,113 +1,38 @@ - -# resource "aws_iam_role" "this" { - -# name = var.bucket_iam_role -# path = "/" - -# assume_role_policy = < /dev/null 2>&1 + build: + commands: + # Set the new version in the parameter store to be deployed by the CI/CD Blue/Green Deployment pipeline. + - export SHORT_VERSION=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c1-7) + - echo Short Version = $SHORT_VERSION + - aws ssm put-parameter --name $NEW_VERSION_PARAMETER_NAME --value $SHORT_VERSION --type String --overwrite + # Start the pipeline by triggering the first s3 source stage + - make -s clean > /dev/null 2>&1 + - git archive -o repository.zip HEAD + - aws s3 cp repository.zip s3://$CICD_ARTIFACT_BUCKET/repository.zip diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/build-cicd-shared-resources-artefact-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/build-cicd-shared-resources-artefact-buildspec.yml new file mode 100644 index 000000000..155fe6a34 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/build-cicd-shared-resources-artefact-buildspec.yml @@ -0,0 +1,19 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +phases: + pre_build: + commands: + - is_pipeline_running=$(aws codepipeline list-pipeline-executions --pipeline-name $CICD_SHARED_RESOURCES_DEPLOYMENT_PIPELINE --query "pipelineExecutionSummaries[?status == 'InProgress'].pipelineExecutionId") + - | + if [[ "$is_pipeline_running" != "[]" ]]; then + echo "Pipeline is already running. Exiting." + exit 1 + fi + - make -s clean > /dev/null 2>&1 + build: + commands: + # Start the pipeline by triggering the first s3 source stage + - make -s clean > /dev/null 2>&1 + - git archive -o repository.zip HEAD + - aws s3 cp repository.zip s3://$CICD_ARTIFACT_BUCKET/repository.zip diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/build-tools-image-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/build-tools-image-buildspec.yml new file mode 100644 index 000000000..2d65cbcc2 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/build-tools-image-buildspec.yml @@ -0,0 +1,44 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +phases: + pre_build: + commands: + - mkdir -p $HOME/.docker/cli-plugins + - wget -O $HOME/.docker/cli-plugins/docker-buildx https://github.com/docker/buildx/releases/download/v0.10.3/buildx-v0.10.3.linux-arm64 + - chmod a+rx $HOME/.docker/cli-plugins/docker-buildx + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - make docker-hub-signin + - docker run --privileged --rm tonistiigi/binfmt --install all + - export DOCKER_BUILDKIT=1 + - export DOCKER_CLI_EXPERIMENTAL=enabled + - docker buildx create --name mybuilder --use + - docker buildx version + build: + commands: + - echo "[Building docker containers]" + - echo VERSION = $(make commit-date-hash-tag) + - export VERSION=$(make commit-date-hash-tag) + - make $BUILD_TARGET VERSION=$VERSION DOCKER_BUILDKIT=1 + - echo "[Pushing images to ECR]" + - unset AWS_ACCESS_KEY_ID + - unset AWS_SECRET_ACCESS_KEY + - unset AWS_SESSION_TOKEN + - make docker-push NAME=$BUILD_ITEM_NAME VERSION=$VERSION + finally: + - unset AWS_ACCESS_KEY_ID + - unset AWS_SECRET_ACCESS_KEY + - unset AWS_SESSION_TOKEN + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - | + if [ $CODEBUILD_BUILD_SUCCEEDING -eq 0 ]; then + make slack-codebuild-notification PROFILE=$PROFILE PIPELINE_NAME="Build $BUILD_ITEM_NAME Image" BUILD_STATUS=failure CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Build Failed Please investigate"; + else + make slack-codebuild-notification PROFILE=$PROFILE PIPELINE_NAME="Build $BUILD_ITEM_NAME Image" BUILD_STATUS=success CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Build Succeeded"; + fi diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-blue-green-environment-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-blue-green-environment-buildspec.yml new file mode 100644 index 000000000..1ec689b35 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-blue-green-environment-buildspec.yml @@ -0,0 +1,44 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +phases: + pre_build: + commands: + - chown -R 999:999 $CODEBUILD_SRC_DIR + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSessionNonProd") + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - make docker-hub-signin + - make docker-pull NAME=serverless VERSION=latest + - unset AWS_ACCESS_KEY_ID + - unset AWS_SECRET_ACCESS_KEY + - unset AWS_SESSION_TOKEN + - | + if [[ "$AWS_ACCOUNT" == "PROD" ]]; then + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_PROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + else + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + fi + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - aws sts get-caller-identity + - make -s clean > /dev/null 2>&1 + build: + commands: + - echo "[Destroying Blue Green Environment]" + - export ENVIRONMENT=$BLUE_GREEN_ENVIRONMENT + - echo PROFILE=$PROFILE + - echo BLUE_GREEN_ENVIRONMENT=$BLUE_GREEN_ENVIRONMENT + - echo ENVIRONMENT=$ENVIRONMENT + - echo SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT + # Find out if the environment exists + - export ENVIRONMENT_DEPLOYED=$(aws cloudformation list-stacks --max-items 1000 | jq --raw-output '.StackSummaries[] | select(.StackName | contains("'$BLUE_GREEN_ENVIRONMENT'"))') + - echo $ENVIRONMENT_DEPLOYED + # Only destroy the environment if it exists + - | + if [ -n "$ENVIRONMENT_DEPLOYED" ]; then + make undeploy-blue-green-environment PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT BLUE_GREEN_ENVIRONMENT=$BLUE_GREEN_ENVIRONMENT TF_CLI_ARGS="-parallelism=30"; + fi + - diff --git a/infrastructure/stacks/development-pipeline/delete-task-environment-from-tag-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-nonprod-environment-from-tag-buildspec.yml similarity index 54% rename from infrastructure/stacks/development-pipeline/delete-task-environment-from-tag-buildspec.yml rename to infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-nonprod-environment-from-tag-buildspec.yml index 5a9530bac..e584f9467 100644 --- a/infrastructure/stacks/development-pipeline/delete-task-environment-from-tag-buildspec.yml +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-nonprod-environment-from-tag-buildspec.yml @@ -5,12 +5,11 @@ phases: pre_build: commands: - chown -R 999:999 $CODEBUILD_SRC_DIR - - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/UECPUPipelineBuildRole" --role-session-name "CodeBuildSession") + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) - make docker-hub-signin - - make docker-pull NAME=serverless VERSION=latest build: commands: - echo $CODEBUILD_WEBHOOK_TRIGGER @@ -19,12 +18,12 @@ phases: - export ENVIRONMENT_DEPLOYED=$(aws cloudformation list-stacks --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE --max-items 1000 | jq --raw-output '.StackSummaries[] | select(.StackName | contains("'$ENVIRONMENT'"))') - echo $ENVIRONMENT_DEPLOYED - if [ -n "$ENVIRONMENT_DEPLOYED" ]; then - make undeploy PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT ; + make undeploy PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT OPTS="-parallelism=30" ; fi finally: - - | - if [ $CODEBUILD_BUILD_SUCCEEDING -eq 0 ]; then - make slack-codebuild-notification PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT PIPELINE_NAME="Delete Task Environment from Tag Codebuild Stage" BUILD_STATUS=failure CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Failed Please investigate" - else - make slack-codebuild-notification PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT PIPELINE_NAME="Delete Task Environment from Tag Codebuild Stage" BUILD_STATUS=success CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Succeeded" - fi + - | + if [ $CODEBUILD_BUILD_SUCCEEDING -eq 0 ]; then + make slack-codebuild-notification PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT PIPELINE_NAME="Delete Nonprod Environment from Tag Codebuild Stage" BUILD_STATUS=failure CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Failed Please investigate" + else + make slack-codebuild-notification PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT PIPELINE_NAME="Delete Nonprod Environment from Tag Codebuild Stage" BUILD_STATUS=success CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Succeeded" + fi diff --git a/infrastructure/stacks/development-pipeline/delete-task-environment-on-pr-merged-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-nonprod-environment-on-pr-merged-buildspec.yml similarity index 55% rename from infrastructure/stacks/development-pipeline/delete-task-environment-on-pr-merged-buildspec.yml rename to infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-nonprod-environment-on-pr-merged-buildspec.yml index 5fb28d60d..cd4d97bcd 100644 --- a/infrastructure/stacks/development-pipeline/delete-task-environment-on-pr-merged-buildspec.yml +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-nonprod-environment-on-pr-merged-buildspec.yml @@ -3,24 +3,19 @@ version: 0.2 phases: install: - runtime-versions: - docker: 19 commands: - wget https://github.com/cli/cli/releases/download/v2.6.0/gh_2.6.0_linux_amd64.rpm - yum localinstall -y gh_2.6.0_linux_amd64.rpm - - gh version pre_build: commands: - - make wait-for-codebuild-to-finish PROJECT_NAME=$SERVERLESS_BUILD_PROJECT_NAME - export GH_TOKEN=$(make secret-fetch NAME=uec-dos-int-tools/deployment | jq -r '.GITHUB_TOKEN') - echo $GH_TOKEN - - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/UECPUPipelineBuildRole" --role-session-name "CodeBuildSession") + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) - aws sts get-caller-identity - make docker-hub-signin - - make docker-pull NAME=serverless VERSION=latest build: commands: - chown -R 999:999 $CODEBUILD_SRC_DIR @@ -29,15 +24,16 @@ phases: - echo $PR_NUMBER - export ENVIRONMENT=$(make -s get-environment-from-pr PR_NUMBER=$PR_NUMBER) - echo $ENVIRONMENT - - export ENVIRONMENT_DEPLOYED=$(make -s is-environment-deployed ENVIRONMENT=$ENVIRONMENT) - - echo $ENVIRONMENT_DEPLOYED - - if [ -n "$ENVIRONMENT_DEPLOYED" ]; then - make undeploy PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT ; + - | + if [[ "$ENVIRONMENT" =~ .*"ds-".* ]]; then + export ENVIRONMENT_DEPLOYED=$(aws lambda get-function --function-name uec-dos-int-$ENVIRONMENT-service-sync) + make undeploy PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT OPTS="-parallelism=30"; fi finally: + - export PR=$(echo $CODEBUILD_WEBHOOK_TRIGGER | sed -e "s|^refs/heads/||") - | if [ $CODEBUILD_BUILD_SUCCEEDING -eq 0 ]; then - make slack-codebuild-notification ENVIRONMENT=$ENVIRONMENT PROFILE=$PROFILE PIPELINE_NAME="Delete Environment After Merge Codebuild Stage" BUILD_STATUS=failure CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Failed Please investigate" + make slack-codebuild-notification PR=$PR ENVIRONMENT=$ENVIRONMENT PROFILE=$PROFILE PIPELINE_NAME="Delete Environment After Merge Codebuild Stage" BUILD_STATUS=failure CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Failed Please investigate" else - make slack-codebuild-notification ENVIRONMENT=$ENVIRONMENT PROFILE=$PROFILE PIPELINE_NAME="Delete Environment After Merge Codebuild Stage" BUILD_STATUS=success CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Succeeded" + make slack-codebuild-notification PR=$PR ENVIRONMENT=$ENVIRONMENT PROFILE=$PROFILE PIPELINE_NAME="Delete Environment After Merge Codebuild Stage" BUILD_STATUS=success CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Succeeded" fi diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-blue-green-environment-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-blue-green-environment-buildspec.yml new file mode 100644 index 000000000..93adb9673 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-blue-green-environment-buildspec.yml @@ -0,0 +1,75 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +# This buildspec works on the expectation that a full environment has already deploy has been done. +# This buildspec will only deploy a new version of the blue-green environment and link it to the shared resources. + +phases: + pre_build: + commands: + - NEW_VERSION_COMMIT_HASH=$(aws ssm get-parameter --name $NEW_VERSION_PARAMETER_NAME --query "Parameter.Value" --output text) + - NEW_VERSION=$(echo $NEW_VERSION_COMMIT_HASH-$PROFILE) + - chown -R 999:999 $CODEBUILD_SRC_DIR + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSessionNonProd") + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - make docker-hub-signin + - unset AWS_ACCESS_KEY_ID + - unset AWS_SECRET_ACCESS_KEY + - unset AWS_SESSION_TOKEN + - | + if [[ "$AWS_ACCOUNT" == "PROD" ]]; then + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_PROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + else + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + fi + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - aws sts get-caller-identity + - make -s clean > /dev/null 2>&1 + build: + commands: + # In this snippet, we are deploying the new blue-green environment + - echo "[Deploying New Version]" + - export ENVIRONMENT=$NEW_VERSION + - export BLUE_GREEN_ENVIRONMENT="$ENVIRONMENT" + - echo PROFILE=$PROFILE + - echo BLUE_GREEN_ENVIRONMENT=$BLUE_GREEN_ENVIRONMENT + - echo ENVIRONMENT=$ENVIRONMENT + - echo SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT + - echo VERSION=$CODEBUILD_RESOLVED_SOURCE_VERSION + - make deploy-blue-green-environment VERSION=$CODEBUILD_RESOLVED_SOURCE_VERSION PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT BLUE_GREEN_ENVIRONMENT=$BLUE_GREEN_ENVIRONMENT TF_CLI_ARGS="-parallelism=30" + + # Getting the current and rollback blue-green environments to be used later. + - echo "[Get current and previous deployed blue-green versions]" + - export ENVIRONMENT="$SHARED_ENVIRONMENT" + - echo SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT + - export CURRENT_VERSION=$(make terraform-output STACKS=blue-green-link OPTS='-raw connected_blue_green_environment' PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT | tail -n -1) + - export PREVIOUS_VERSION=$(make terraform-output STACKS=blue-green-link OPTS='-raw previous_blue_green_environment' PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT | tail -n -1) + + - echo "[Unlink Previous Version]" + - echo CURRENT_VERSION=$CURRENT_VERSION + - echo PREVIOUS_VERSION=$PREVIOUS_VERSION + - make unlink-blue-green-environment PROFILE=$PROFILE ENVIRONMENT=$SHARED_ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT TF_CLI_ARGS="-parallelism=30" + + - echo "[Linking New Version]" + - echo CURRENT_VERSION=$CURRENT_VERSION + - echo PREVIOUS_VERSION=$PREVIOUS_VERSION + - echo NEW_VERSION=$NEW_VERSION + - make link-blue-green-environment VERSION=$CODEBUILD_RESOLVED_SOURCE_VERSION PROFILE=$PROFILE ENVIRONMENT=$SHARED_ENVIRONMENT BLUE_GREEN_ENVIRONMENT=$BLUE_GREEN_ENVIRONMENT TF_VAR_previous_blue_green_environment=$CURRENT_VERSION TF_CLI_ARGS="-parallelism=30" + + - echo "[Destroy Previous Rollback Version]" + - export PREVIOUS_VERSION_COMMIT_HASH=$(echo $PREVIOUS_VERSION | cut -d'-' -f1) + # In this snippet, we are triggering the codebuild stage to destroy the previous rollback blue-green environment + - mkdir -p $CODEBUILD_SRC_DIR/git_repo + - cd $CODEBUILD_SRC_DIR/git_repo + - git clone $GIT_REPO_URL . + - git checkout $PREVIOUS_VERSION_COMMIT_HASH || true + - export FULL_PREVIOUS_VERSION=$(git rev-parse $PREVIOUS_VERSION_COMMIT_HASH) || true + - echo FULL_PREVIOUS_VERSION=$FULL_PREVIOUS_VERSION + - unset AWS_ACCESS_KEY_ID + - unset AWS_SECRET_ACCESS_KEY + - unset AWS_SESSION_TOKEN + - aws codebuild start-build --project-name $DELETE_BLUE_GREEN_ENVIRONMENT_CODEBUILD_NAME --source-version $FULL_PREVIOUS_VERSION --environment-variables-override name=BLUE_GREEN_ENVIRONMENT,value=$PREVIOUS_VERSION,type=PLAINTEXT name=AWS_ACCOUNT,value=$AWS_ACCOUNT,type=PLAINTEXT name=PROFILE,value=$PROFILE,type=PLAINTEXT name=SHARED_ENVIRONMENT,value=$SHARED_ENVIRONMENT,type=PLAINTEXT diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-full-environment-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-full-environment-buildspec.yml new file mode 100644 index 000000000..634882eec --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-full-environment-buildspec.yml @@ -0,0 +1,33 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +phases: + pre_build: + commands: + - chown -R 999:999 $CODEBUILD_SRC_DIR + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSessionNonProd") + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - make docker-hub-signin + - unset AWS_ACCESS_KEY_ID + - unset AWS_SECRET_ACCESS_KEY + - unset AWS_SESSION_TOKEN + - | + if [[ "$AWS_ACCOUNT" == "PROD" ]]; then + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_PROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + else + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + fi + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - aws sts get-caller-identity + - make -s clean + build: + commands: + - echo "[Deploying]" + - echo AWS_ACCOUNT=$AWS_ACCOUNT + - echo ENVIRONMENT=$ENVIRONMENT + - echo PROFILE=$PROFILE + - make deploy VERSION=$CODEBUILD_RESOLVED_SOURCE_VERSION PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT TF_CLI_ARGS="-parallelism=30" diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-shared-resources-environment-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-shared-resources-environment-buildspec.yml new file mode 100644 index 000000000..8657e5c0e --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-shared-resources-environment-buildspec.yml @@ -0,0 +1,54 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +# This buildspec works on the expectation that a full environment has already deploy has been done. +# This buildspec will only deploy an incremental change to the shared resources. + +phases: + pre_build: + commands: + - chown -R 999:999 $CODEBUILD_SRC_DIR + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSessionNonProd") + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - make docker-hub-signin + - unset AWS_ACCESS_KEY_ID + - unset AWS_SECRET_ACCESS_KEY + - unset AWS_SESSION_TOKEN + - | + if [[ "$AWS_ACCOUNT" == "PROD" ]]; then + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_PROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + else + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + fi + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - aws sts get-caller-identity + - make -s clean > /dev/null 2>&1 + build: + commands: + - export ENVIRONMENT=$SHARED_ENVIRONMENT + - echo PROFILE=$PROFILE + - echo ENVIRONMENT=$ENVIRONMENT + - echo SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT + + # Retain the existing blue-green-link variables for later use + - echo "[Get versions from blue-green-link]" + - export CURRENT_VERSION=$(make terraform-output STACKS=blue-green-link OPTS='-raw connected_blue_green_environment' PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT | tail -n -1) + - export PREVIOUS_VERSION=$(make terraform-output STACKS=blue-green-link OPTS='-raw previous_blue_green_environment' PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT | tail -n -1) + + # Remove the blue-green-link terraform stack to allow changes to be made to the shared resources + - echo "[Unlink Existing Version]" + - echo BLUE/GREEN CURRENT_VERSION=$CURRENT_VERSION + - echo BLUE/GREEN PREVIOUS_VERSION=$PREVIOUS_VERSION + - make unlink-blue-green-environment PROFILE=$PROFILE ENVIRONMENT=$SHARED_ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT BLUE_GREEN_ENVIRONMENT=$CURRENT_VERSION TF_CLI_ARGS="-parallelism=30" + + # Deploy the shared resources Terraform stack + - echo "[Update Shared Resources]" + - make deploy-shared-resources ENVIRONMENT=$ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT + + # Link the shared resources to the environment back to the current version of the blue-green environment + - echo "[Link Back Existing Version]" + - make link-blue-green-environment PROFILE=$PROFILE ENVIRONMENT=$SHARED_ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT BLUE_GREEN_ENVIRONMENT=$CURRENT_VERSION TF_CLI_ARGS="-parallelism=30" TF_VAR_blue_green_environment=$CURRENT_VERSION TF_VAR_previous_blue_green_environment=$PREVIOUS_VERSION diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/integration-tests-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/integration-tests-buildspec.yml new file mode 100644 index 000000000..4e670a2cd --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/integration-tests-buildspec.yml @@ -0,0 +1,27 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +phases: + pre_build: + commands: + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - make docker-hub-signin + - make docker-pull NAME=tester VERSION=latest + - | + if [[ -z "$BLUE_GREEN_ENVIRONMENT" ]]; then + export BLUE_GREEN_ENVIRONMENT=$(make terraform-output STACKS=blue-green-link ENVIRONMENT=$SHARED_ENVIRONMENT OPTS='-raw connected_blue_green_environment' | tail -n1) + fi + build: + commands: + - echo $BLUE_GREEN_ENVIRONMENT + - make integration-test PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT TAG=$TAG PARALLEL_TEST_COUNT=20 +reports: + PytestIntegrationReport: + files: + - "testresults.json" + base-directory: $CODEBUILD_SRC_DIR/test/integration + discard-paths: yes + file-format: CUCUMBERJSON diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/production-smoke-test-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/production-smoke-test-buildspec.yml new file mode 100644 index 000000000..b5fc2899d --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/production-smoke-test-buildspec.yml @@ -0,0 +1,37 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +phases: + pre_build: + commands: + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - make docker-hub-signin + - make docker-pull NAME=tester + - unset AWS_ACCESS_KEY_ID + - unset AWS_SECRET_ACCESS_KEY + - unset AWS_SESSION_TOKEN + - | + if [[ "$AWS_ACCOUNT" == "PROD" ]]; then + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_PROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + else + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + fi + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - export BLUE_GREEN_ENVIRONMENT=$(make terraform-output STACKS=blue-green-link ENVIRONMENT=$ENVIRONMENT OPTS='-raw connected_blue_green_environment' | tail -n1) + - echo "BLUE_GREEN_ENVIRONMENT=$BLUE_GREEN_ENVIRONMENT" + build: + commands: + - make production-smoke-test PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT BLUE_GREEN_ENVIRONMENT=$BLUE_GREEN_ENVIRONMENT + +reports: + PytestSmokeTestReport: + files: + - "testresults.json" + base-directory: $CODEBUILD_SRC_DIR/test/smoke/results + discard-paths: yes + file-format: CUCUMBERJSON diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/rollback-blue-green-deployment-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/rollback-blue-green-deployment-buildspec.yml new file mode 100644 index 000000000..d47329dab --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/rollback-blue-green-deployment-buildspec.yml @@ -0,0 +1,46 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +phases: + pre_build: + commands: + # Work out variables + - echo $CODEBUILD_WEBHOOK_HEAD_REF + - export TAG=$(echo $CODEBUILD_WEBHOOK_HEAD_REF | sed -e "s|^refs/tags||") + - export PROFILE="$(cut -d'_' -f2 <<<$TAG)" + - export SHARED_ENVIRONMENT="$(cut -d'_' -f3 <<<$TAG)" + - echo PROFILE=$PROFILE + - echo SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT + # Sign into nonprod to access docker hub + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - make docker-hub-signin + - unset AWS_ACCESS_KEY_ID + - unset AWS_SECRET_ACCESS_KEY + - unset AWS_SESSION_TOKEN + # Sign into relevant account to for rollbacking environment + - | + if [[ "$PROFILE" == "live" ]] || [[ "$PROFILE" == "demo" ]]; then + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_PROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + else + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + fi + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - aws sts get-caller-identity + build: + commands: + # Get current and previous versions of the environment + - export CURRENT_VERSION=$(make terraform-output STACKS=blue-green-link OPTS='-raw connected_blue_green_environment' PROFILE=$PROFILE ENVIRONMENT=$SHARED_ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT | tail -n -1) + - export PREVIOUS_VERSION=$(make terraform-output STACKS=blue-green-link OPTS='-raw previous_blue_green_environment' PROFILE=$PROFILE ENVIRONMENT=$SHARED_ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT | tail -n -1) + # CURRENT_VERSION is the version of the environment that is currently linked to the environment at the start of the build + # PREVIOUS_VERSION is the version of the environment that was not linked to the environment at the start of the build + - echo CURRENT_VERSION=$CURRENT_VERSION + - echo PREVIOUS_VERSION=$PREVIOUS_VERSION + # Unlink environment from current version + - make unlink-blue-green-environment BLUE_GREEN_ENVIRONMENT=$PREVIOUS_VERSION PROFILE=$PROFILE ENVIRONMENT=$SHARED_ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT TF_CLI_ARGS="-parallelism=30" + # Rollback environment to previous version + - make link-blue-green-environment BLUE_GREEN_ENVIRONMENT=$PREVIOUS_VERSION PROFILE=$PROFILE ENVIRONMENT=$SHARED_ENVIRONMENT SHARED_ENVIRONMENT=$SHARED_ENVIRONMENT TF_VAR_previous_blue_green_environment=$CURRENT_VERSION TF_CLI_ARGS="-parallelism=30" diff --git a/infrastructure/stacks/development-pipeline/build-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/setup-dos-environment-buildspec.yml similarity index 52% rename from infrastructure/stacks/development-pipeline/build-buildspec.yml rename to infrastructure/stacks/development-and-deployment-tools/buildspecs/setup-dos-environment-buildspec.yml index f2388fe60..e937af837 100644 --- a/infrastructure/stacks/development-pipeline/build-buildspec.yml +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/setup-dos-environment-buildspec.yml @@ -4,17 +4,10 @@ version: 0.2 phases: pre_build: commands: - - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/UECPUPipelineBuildRole" --role-session-name "CodeBuildSession") + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) - - make docker-hub-signin build: commands: - - echo "[Building docker containers]" - - make $BUILD_TARGET VERSION=$CODEBUILD_RESOLVED_SOURCE_VERSION DOCKER_BUILDKIT=1 FROM_CACHE=true - - echo "[Pushing images to ECR]" - - unset AWS_ACCESS_KEY_ID - - unset AWS_SECRET_ACCESS_KEY - - unset AWS_SESSION_TOKEN - - make docker-push NAME=$BUILD_ITEM_NAME + - make trigger-dos-deployment-pipeline diff --git a/infrastructure/stacks/development-and-deployment-tools/buildspecs/trigger-rollback-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/trigger-rollback-buildspec.yml new file mode 100644 index 000000000..48672c58e --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/trigger-rollback-buildspec.yml @@ -0,0 +1,28 @@ +# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html +version: 0.2 + +phases: + pre_build: + commands: + - export TOKEN=$(make -s secret-get-existing-value NAME=uec-dos-int-tools/deployment KEY=GITHUB_TOKEN) + - | + if [[ "$AWS_ACCOUNT" == "PROD" ]]; then + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_PROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + else + temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") + fi + - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) + - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) + - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) + - echo PROFILE=$PROFILE + - echo ENVIRONMENT=$ENVIRONMENT + - export PREVIOUS_VERSION=$(make terraform-output STACKS=blue-green-link OPTS='-raw previous_blue_green_environment' PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT | tail -n -1 | cut -d - -f1) + - mkdir -p $CODEBUILD_SRC_DIR/git_repo + - cd $CODEBUILD_SRC_DIR/git_repo + - git clone https://$TOKEN@github.com/$PROJECT_REPO + - cd dos-integration + - git checkout $PREVIOUS_VERSION + build: + commands: + - make tag-commit-to-rollback-blue-green-environment PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT COMMIT=$PREVIOUS_VERSION + - sleep 300 # wait for the rollback to complete diff --git a/infrastructure/stacks/development-pipeline/deploy-buildspec.yml b/infrastructure/stacks/development-and-deployment-tools/buildspecs/unit-tests-buildspec.yml similarity index 56% rename from infrastructure/stacks/development-pipeline/deploy-buildspec.yml rename to infrastructure/stacks/development-and-deployment-tools/buildspecs/unit-tests-buildspec.yml index dbd28c09f..0b13aadc3 100644 --- a/infrastructure/stacks/development-pipeline/deploy-buildspec.yml +++ b/infrastructure/stacks/development-and-deployment-tools/buildspecs/unit-tests-buildspec.yml @@ -4,15 +4,20 @@ version: 0.2 phases: pre_build: commands: - - chown -R 999:999 $CODEBUILD_SRC_DIR - - make wait-for-codebuild-to-finish PROJECT_NAME=$SERVERLESS_BUILD_PROJECT_NAME - - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/UECPUPipelineBuildRole" --role-session-name "CodeBuildSession") + - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/$PIPELINE_BUILD_ROLE" --role-session-name "CodeBuildSession") - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) - make docker-hub-signin - - make docker-pull NAME=serverless VERSION=latest build: commands: - - echo "[Deploying]" - - make deploy VERSION=$CODEBUILD_RESOLVED_SOURCE_VERSION PROFILE=$PROFILE OPTS="-parallelism=30" + - make docker-pull NAME=tester VERSION=latest + - make unit-test + +reports: + unit-tests: + files: + - testresults.xml + base-directory: $CODEBUILD_SRC_DIR + discard-paths: yes + file-format: JUNITXML diff --git a/infrastructure/stacks/development-and-deployment-tools/cicd_blue_green_deployment_pipeline.tf b/infrastructure/stacks/development-and-deployment-tools/cicd_blue_green_deployment_pipeline.tf new file mode 100644 index 000000000..77a4d0666 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/cicd_blue_green_deployment_pipeline.tf @@ -0,0 +1,343 @@ +resource "aws_codepipeline" "cicd_blue_green_deployment_pipeline" { + #checkov:skip=CKV_AWS_219 + name = var.cicd_blue_green_deployment_pipeline_name + role_arn = data.aws_iam_role.pipeline_role.arn + + artifact_store { + location = var.cicd_blue_green_deployment_pipeline_artefact_bucket + type = "S3" + } + + + stage { + name = "Source" + action { + category = "Source" + name = "Source" + owner = "AWS" + provider = "S3" + version = "1" + output_artifacts = ["source_output"] + configuration = { + S3Bucket = var.cicd_blue_green_deployment_pipeline_artefact_bucket + S3ObjectKey = "repository.zip" + PollForSourceChanges = "True" + } + } + } + + stage { + name = "Unit_Tests" + action { + name = "UnitTests" + category = "Build" + owner = "AWS" + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = "${var.project_id}-${var.environment}-unit-test-stage" + } + } + } + stage { + name = "Build" + dynamic "action" { + for_each = local.to_build + content { + name = "Build_${action.key}" + category = "Build" + owner = "AWS" + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.build_image_stage.name + EnvironmentVariables = jsonencode([ + { + name = "BUILD_ITEM_NAME" + value = "${action.key}" + type = "PLAINTEXT" + } + ]) + } + } + } + } + stage { + name = "Deploy_Test_Environment" + dynamic "action" { + for_each = local.cicd_nonprod_environments + content { + name = "Deploy_${action.value["SHARED_ENVIRONMENT"]}" + category = "Build" + owner = "AWS" + run_order = 1 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.deploy_blue_green_environment_stage.name + EnvironmentVariables = jsonencode([ + { + name = "AWS_ACCOUNT" + value = "${action.value["AWS_ACCOUNT"]}" + type = "PLAINTEXT" + }, + { + name = "PROFILE" + value = "${action.value["PROFILE"]}" + type = "PLAINTEXT" + }, + { + name = "SHARED_ENVIRONMENT" + value = "${action.value["SHARED_ENVIRONMENT"]}" + type = "PLAINTEXT" + } + ]) + } + } + } + dynamic "action" { + for_each = local.integration_test_tags + content { + name = "Integration_Test_${action.key}" + category = "Build" + owner = "AWS" + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + run_order = 2 + configuration = { + ProjectName = aws_codebuild_project.integration_tests[action.key].name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "dev" + type = "PLAINTEXT" + }, + { + name = "SHARED_ENVIRONMENT" + value = "cicd-test" + type = "PLAINTEXT" + } + ]) + } + } + } + } + stage { + name = "Deploy_Cicd_Release_Environment" + dynamic "action" { + for_each = local.cicd_prod_environments + content { + name = "Deploy_${action.value["SHARED_ENVIRONMENT"]}" + category = "Build" + owner = "AWS" + run_order = 1 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.deploy_blue_green_environment_stage.name + EnvironmentVariables = jsonencode([ + { + name = "AWS_ACCOUNT" + value = "${action.value["AWS_ACCOUNT"]}" + type = "PLAINTEXT" + }, + { + name = "PROFILE" + value = "${action.value["PROFILE"]}" + type = "PLAINTEXT" + }, + { + name = "SHARED_ENVIRONMENT" + value = "${action.value["SHARED_ENVIRONMENT"]}" + type = "PLAINTEXT" + } + ]) + } + } + } + action { + name = "Smoke_Test_Latest_Version" + category = "Build" + owner = "AWS" + run_order = 2 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.production_smoke_test.name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "demo" + type = "PLAINTEXT" + }, + { + name = "ENVIRONMENT" + value = "cicd-release" + type = "PLAINTEXT" + }, + { + name = "AWS_ACCOUNT" + value = "PROD" + type = "PLAINTEXT" + } + ]) + } + } + + action { + name = "Rollback_To_Previous_Version" + category = "Build" + owner = "AWS" + run_order = 3 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.trigger_rollback.name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "demo" + type = "PLAINTEXT" + }, + { + name = "ENVIRONMENT" + value = "cicd-release" + type = "PLAINTEXT" + }, + { + name = "AWS_ACCOUNT" + value = "PROD" + type = "PLAINTEXT" + } + ]) + } + } + action { + name = "Smoke_Test_Previous_Version" + category = "Build" + owner = "AWS" + run_order = 4 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.production_smoke_test.name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "demo" + type = "PLAINTEXT" + }, + { + name = "ENVIRONMENT" + value = "cicd-release" + type = "PLAINTEXT" + }, + { + name = "AWS_ACCOUNT" + value = "PROD" + type = "PLAINTEXT" + } + ]) + } + } + action { + name = "Rollback_To_Latest_Version" + category = "Build" + owner = "AWS" + run_order = 5 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.trigger_rollback.name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "demo" + type = "PLAINTEXT" + }, + { + name = "ENVIRONMENT" + value = "cicd-release" + type = "PLAINTEXT" + }, + { + name = "AWS_ACCOUNT" + value = "PROD" + type = "PLAINTEXT" + } + ]) + } + } + } + stage { + name = "Approve" + action { + name = "Approve_Live_Deployment" + category = "Approval" + owner = "AWS" + provider = "Manual" + version = "1" + configuration = { + CustomData = "Approve the deployment to the live environment" + } + } + } + stage { + name = "Deploy_Live_Environment" + action { + name = "Deploy_Live" + category = "Build" + owner = "AWS" + run_order = 1 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.deploy_blue_green_environment_stage.name + EnvironmentVariables = jsonencode([ + { + name = "AWS_ACCOUNT" + value = "PROD" + type = "PLAINTEXT" + }, + { + name = "PROFILE" + value = "live" + type = "PLAINTEXT" + }, + { + name = "SHARED_ENVIRONMENT" + value = "live" + type = "PLAINTEXT" + } + ]) + } + } + } + depends_on = [ + module.cicd_blue_green_deployment_pipeline_artefact_bucket, + aws_codebuild_project.unit_tests_stage, + aws_codebuild_project.build_image_stage, + aws_codebuild_project.integration_tests, + aws_codebuild_project.deploy_blue_green_environment_stage, + ] +} + + +module "cicd_blue_green_deployment_pipeline_artefact_bucket" { + source = "../../modules/s3" + name = var.cicd_blue_green_deployment_pipeline_artefact_bucket + project_id = var.project_id + acl = "private" + versioning_enabled = "true" + force_destroy = "true" +} diff --git a/infrastructure/stacks/development-and-deployment-tools/cicd_blue_green_rollback.tf b/infrastructure/stacks/development-and-deployment-tools/cicd_blue_green_rollback.tf new file mode 100644 index 000000000..4b5bffadc --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/cicd_blue_green_rollback.tf @@ -0,0 +1,68 @@ +resource "aws_codebuild_webhook" "blue_green_rollback_webhook" { + project_name = "${var.project_id}-${var.environment}-blue-green-rollback-stage" + build_type = "BUILD" + filter_group { + filter { + type = "EVENT" + pattern = "PUSH" + } + + filter { + type = "HEAD_REF" + pattern = "^refs/tags/.*_blue_green_rollback" + } + } + depends_on = [aws_codebuild_project.blue_green_rollback_stage] +} + +resource "aws_codebuild_project" "blue_green_rollback_stage" { + name = "${var.project_id}-${var.environment}-blue-green-rollback-stage" + description = "Rolls back blue-green deployment" + build_timeout = "60" + queued_timeout = "5" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "NO_ARTIFACTS" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_DOCKER_LAYER_CACHE"] + } + + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + environment_variable { + name = "CB_PROJECT_NAME" + value = "${var.project_id}-${var.environment}-blue-green-rollback-stage" + } + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + + } + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-blue-green-rollback-stage" + stream_name = "" + } + } + source { + type = "GITHUB" + git_clone_depth = 0 + location = var.github_url + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/rollback-blue-green-deployment-buildspec.yml" + } + +} diff --git a/infrastructure/stacks/development-and-deployment-tools/cicd_shared_resources_pipeline.tf b/infrastructure/stacks/development-and-deployment-tools/cicd_shared_resources_pipeline.tf new file mode 100644 index 000000000..aab7e5b4b --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/cicd_shared_resources_pipeline.tf @@ -0,0 +1,214 @@ +resource "aws_codepipeline" "cicd_shared_resources_deployment_pipeline" { + #checkov:skip=CKV_AWS_219 + name = var.cicd_shared_resources_deployment_pipeline_name + role_arn = data.aws_iam_role.pipeline_role.arn + + artifact_store { + location = var.cicd_shared_resoures_deployment_pipeline_artefact_bucket + type = "S3" + } + + stage { + name = "Source" + action { + category = "Source" + name = "Source" + owner = "AWS" + provider = "S3" + version = "1" + output_artifacts = ["source_output"] + configuration = { + S3Bucket = var.cicd_shared_resoures_deployment_pipeline_artefact_bucket + S3ObjectKey = "repository.zip" + PollForSourceChanges = "True" + } + } + } + stage { + name = "Deploy_Test_Environment" + dynamic "action" { + for_each = local.cicd_nonprod_environments + content { + name = "Deploy_${action.value["SHARED_ENVIRONMENT"]}" + category = "Build" + owner = "AWS" + run_order = 1 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.deploy_shared_resources_environment_stage.name + EnvironmentVariables = jsonencode([ + { + name = "AWS_ACCOUNT" + value = "${action.value["AWS_ACCOUNT"]}" + type = "PLAINTEXT" + }, + { + name = "PROFILE" + value = "${action.value["PROFILE"]}" + type = "PLAINTEXT" + }, + { + name = "SHARED_ENVIRONMENT" + value = "${action.value["SHARED_ENVIRONMENT"]}" + type = "PLAINTEXT" + } + ]) + } + } + } + dynamic "action" { + for_each = local.integration_test_tags + content { + name = "Integration_Test_${action.key}" + category = "Build" + owner = "AWS" + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + run_order = 2 + configuration = { + ProjectName = aws_codebuild_project.integration_tests[action.key].name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "dev" + type = "PLAINTEXT" + }, + { + name = "SHARED_ENVIRONMENT" + value = "cicd-test" + type = "PLAINTEXT" + } + ]) + } + } + } + } + stage { + name = "Deploy_Cicd_Release_Environment" + dynamic "action" { + for_each = local.cicd_prod_environments + content { + name = "Deploy_${action.value["SHARED_ENVIRONMENT"]}" + category = "Build" + owner = "AWS" + run_order = 1 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.deploy_shared_resources_environment_stage.name + EnvironmentVariables = jsonencode([ + { + name = "AWS_ACCOUNT" + value = "${action.value["AWS_ACCOUNT"]}" + type = "PLAINTEXT" + }, + { + name = "PROFILE" + value = "${action.value["PROFILE"]}" + type = "PLAINTEXT" + }, + { + name = "SHARED_ENVIRONMENT" + value = "${action.value["SHARED_ENVIRONMENT"]}" + type = "PLAINTEXT" + } + ]) + } + } + } + action { + name = "Smoke_Test_New_Version" + category = "Build" + owner = "AWS" + run_order = 2 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.production_smoke_test.name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "demo" + type = "PLAINTEXT" + }, + { + name = "ENVIRONMENT" + value = "cicd-release" + type = "PLAINTEXT" + }, + { + name = "AWS_ACCOUNT" + value = "PROD" + type = "PLAINTEXT" + } + ]) + } + } + } + stage { + name = "Approve" + + action { + name = "Approve_Live_Deployment" + category = "Approval" + owner = "AWS" + provider = "Manual" + version = "1" + configuration = { + CustomData = "Approve the deployment to the live environment" + } + } + } + stage { + name = "Deploy_Live_Environment" + action { + name = "Deploy_Live" + category = "Build" + owner = "AWS" + run_order = 1 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.deploy_shared_resources_environment_stage.name + EnvironmentVariables = jsonencode([ + { + name = "AWS_ACCOUNT" + value = "PROD" + type = "PLAINTEXT" + }, + { + name = "PROFILE" + value = "live" + type = "PLAINTEXT" + }, + { + name = "SHARED_ENVIRONMENT" + value = "live" + type = "PLAINTEXT" + } + ]) + } + } + } + depends_on = [ + module.cicd_blue_green_deployment_pipeline_artefact_bucket, + aws_codebuild_project.unit_tests_stage, + aws_codebuild_project.integration_tests, + aws_codebuild_project.deploy_shared_resources_environment_stage, + ] +} + +module "cicd_shared_resoures_deployment_pipeline_artefact_bucket" { + source = "../../modules/s3" + name = var.cicd_shared_resoures_deployment_pipeline_artefact_bucket + project_id = var.project_id + acl = "private" + versioning_enabled = "true" + force_destroy = "true" +} diff --git a/infrastructure/stacks/development-and-deployment-tools/data.tf b/infrastructure/stacks/development-and-deployment-tools/data.tf new file mode 100644 index 000000000..4bc4fd20e --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/data.tf @@ -0,0 +1,37 @@ +# ############## +# # IAM +# ############## + +data "aws_iam_role" "pipeline_role" { + name = "UECDoSINTPipelineRole" +} + +# ############## +# # VPC +# ############## + +data "aws_vpc" "texas_mgmt_vpc" { + tags = { + "Name" = var.mgmt_vpc_name + } +} +data "aws_subnet" "vpc_subnet_one" { + filter { + name = "tag:Name" + values = ["${var.mgmt_vpc_name}-private-${var.aws_region}a"] + } +} + +data "aws_subnet" "vpc_subnet_two" { + filter { + name = "tag:Name" + values = ["${var.mgmt_vpc_name}-private-${var.aws_region}b"] + } +} + +data "aws_subnet" "vpc_subnet_three" { + filter { + name = "tag:Name" + values = ["${var.mgmt_vpc_name}-private-${var.aws_region}c"] + } +} diff --git a/infrastructure/stacks/development-and-deployment-tools/delete_blue_green_environment.tf b/infrastructure/stacks/development-and-deployment-tools/delete_blue_green_environment.tf new file mode 100644 index 000000000..d26c72240 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/delete_blue_green_environment.tf @@ -0,0 +1,44 @@ +resource "aws_codebuild_project" "delete_blue_green_environment" { + name = "${var.project_id}-${var.environment}-delete-blue-green-environment" + description = "Delete Blue/Green Environments" + build_timeout = "60" + queued_timeout = "10" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "NO_ARTIFACTS" + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + environment_variable { + name = "CB_PROJECT_NAME" + value = "${var.project_id}-${var.environment}-delete-blue-green-environment" + } + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + + } + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-delete-blue-green-environment" + stream_name = "" + } + } + source { + type = "GITHUB" + git_clone_depth = 0 + location = var.github_url + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-blue-green-environment-buildspec.yml" + } +} diff --git a/infrastructure/stacks/development-and-deployment-tools/delete_nonprod_environment_from_tag.tf b/infrastructure/stacks/development-and-deployment-tools/delete_nonprod_environment_from_tag.tf new file mode 100644 index 000000000..02936c0e4 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/delete_nonprod_environment_from_tag.tf @@ -0,0 +1,71 @@ +resource "aws_codebuild_webhook" "destroy_environment_from_tag_deployment_webhook" { + project_name = aws_codebuild_project.destroy_environment_from_tag.name + build_type = "BUILD" + filter_group { + filter { + type = "EVENT" + pattern = "PUSH" + } + + filter { + type = "HEAD_REF" + pattern = "^refs/tags/.*-destroy.*$" + } + } +} + +resource "aws_codebuild_project" "destroy_environment_from_tag" { + name = "${var.project_id}-${var.environment}-destroy-nonprod-environments-stage" + description = "Destroys nonprod environment based on tag" + build_timeout = "30" + queued_timeout = "5" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "NO_ARTIFACTS" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_DOCKER_LAYER_CACHE"] + } + + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + environment_variable { + name = "PROFILE" + value = "dev" + } + environment_variable { + name = "CB_PROJECT_NAME" + value = "${var.project_id}-${var.environment}-destroy-nonprod-environments-stage" + } + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + + } + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-destroy-nonprod-environments-stage" + stream_name = "" + } + } + source { + type = "GITHUB" + git_clone_depth = 0 + location = var.github_url + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-nonprod-environment-from-tag-buildspec.yml" + } + +} diff --git a/infrastructure/stacks/development-and-deployment-tools/delete_nonprod_environment_on_pr_merged.tf b/infrastructure/stacks/development-and-deployment-tools/delete_nonprod_environment_on_pr_merged.tf new file mode 100644 index 000000000..df919470b --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/delete_nonprod_environment_on_pr_merged.tf @@ -0,0 +1,70 @@ +resource "aws_codebuild_webhook" "destroy_nonprod_environment_on_pr_merged_deployment_webhook" { + project_name = aws_codebuild_project.destroy_nonprod_environment_on_pr_merged.name + build_type = "BUILD" + filter_group { + filter { + type = "EVENT" + pattern = "PULL_REQUEST_MERGED" + } + filter { + type = "HEAD_REF" + pattern = "refs/heads/task" + } + } +} + +resource "aws_codebuild_project" "destroy_nonprod_environment_on_pr_merged" { + name = "${var.project_id}-${var.environment}-destroy-nonprod-environment-on-pr-merged-stage" + description = "Destroys nonprod environment based on pr merged" + build_timeout = "30" + queued_timeout = "5" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "NO_ARTIFACTS" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_DOCKER_LAYER_CACHE"] + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + environment_variable { + name = "PROFILE" + value = "dev" + } + environment_variable { + name = "CB_PROJECT_NAME" + value = "${var.project_id}-${var.environment}-destroy-nonprod-environment-on-pr-merged-stage" + } + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + + + } + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-destroy-nonprod-environment-on-pr-merged-stage" + stream_name = "" + } + } + source { + type = "GITHUB" + git_clone_depth = 0 + location = var.github_url + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/delete-nonprod-environment-on-pr-merged-buildspec.yml" + } + +} diff --git a/infrastructure/stacks/development-and-deployment-tools/development_pipeline.tf b/infrastructure/stacks/development-and-deployment-tools/development_pipeline.tf new file mode 100644 index 000000000..6276699c5 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/development_pipeline.tf @@ -0,0 +1,216 @@ +resource "aws_codepipeline" "development_pipeline" { + #checkov:skip=CKV_AWS_219 + name = "${var.project_id}-${var.environment}-development-pipeline" + role_arn = data.aws_iam_role.pipeline_role.arn + + artifact_store { + location = "${var.project_id}-${var.environment}-development-pipeline-artefact-bucket" + type = "S3" + } + + stage { + name = "Source" + action { + category = "Source" + name = "Source" + owner = "AWS" + provider = "CodeStarSourceConnection" + version = "1" + output_artifacts = ["source_output"] + + configuration = { + ConnectionArn = aws_codestarconnections_connection.github.arn + FullRepositoryId = "${var.github_owner}/${var.github_repo}" + BranchName = var.development_pipeline_branch_name + DetectChanges = true + } + } + } + + stage { + name = "Unit_Tests" + action { + name = "UnitTests" + category = "Build" + owner = "AWS" + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.unit_tests_stage.name + } + } + } + stage { + name = "Build" + dynamic "action" { + for_each = local.to_build + content { + name = "Build_${action.key}" + category = "Build" + owner = "AWS" + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.build_image_stage.name + EnvironmentVariables = jsonencode([ + { + name = "BUILD_ITEM_NAME" + value = "${action.key}" + type = "PLAINTEXT" + } + ]) + } + } + } + } + stage { + name = "Deploy_Test_Environments" + dynamic "action" { + for_each = local.development_nonprod_environments + content { + name = "Deploy_${action.value["ENVIRONMENT"]}" + category = "Build" + owner = "AWS" + run_order = 1 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.full_deploy_stage.name + EnvironmentVariables = jsonencode([ + { + name = "AWS_ACCOUNT" + value = "${action.value["AWS_ACCOUNT"]}" + type = "PLAINTEXT" + }, + { + name = "PROFILE" + value = "${action.value["PROFILE"]}" + type = "PLAINTEXT" + }, + { + name = "ENVIRONMENT" + value = "${action.value["ENVIRONMENT"]}" + type = "PLAINTEXT" + } + ]) + } + } + } + dynamic "action" { + for_each = local.integration_test_tags + content { + name = "Integration_Test_${action.key}" + category = "Build" + owner = "AWS" + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + run_order = 2 + configuration = { + ProjectName = aws_codebuild_project.integration_tests[action.key].name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "dev" + type = "PLAINTEXT" + }, + { + name = "SHARED_ENVIRONMENT" + value = "test" + type = "PLAINTEXT" + }, + { + name = "BLUE_GREEN_ENVIRONMENT" + value = "test" + type = "PLAINTEXT" + } + ]) + } + } + } + } + stage { + name = "Deploy_Prod_Environments" + action { + name = "Deploy_Demo" + category = "Build" + owner = "AWS" + run_order = 1 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.full_deploy_stage.name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "demo" + type = "PLAINTEXT" + }, + { + name = "ENVIRONMENT" + value = "demo" + type = "PLAINTEXT" + }, + { + name = "AWS_ACCOUNT" + value = "PROD" + type = "PLAINTEXT" + } + ]) + } + } + + action { + name = "Smoke_Test_Demo" + category = "Build" + owner = "AWS" + run_order = 2 + provider = "CodeBuild" + input_artifacts = ["source_output"] + version = "1" + configuration = { + ProjectName = aws_codebuild_project.production_smoke_test.name + EnvironmentVariables = jsonencode([ + { + name = "PROFILE" + value = "demo" + type = "PLAINTEXT" + }, + { + name = "ENVIRONMENT" + value = "demo" + type = "PLAINTEXT" + }, + { + name = "AWS_ACCOUNT" + value = "PROD" + type = "PLAINTEXT" + } + ]) + } + } + } + + depends_on = [ + module.development_pipeline_artefact_bucket, + aws_codebuild_project.unit_tests_stage, + aws_codebuild_project.build_image_stage, + aws_codebuild_project.full_deploy_stage, + aws_codebuild_project.integration_tests, + aws_codebuild_project.production_smoke_test, + ] +} + + +module "development_pipeline_artefact_bucket" { + source = "../../modules/s3" + name = "${var.project_id}-${var.environment}-development-pipeline-artefact-bucket" + project_id = var.project_id + versioning_enabled = "true" + force_destroy = "true" + object_ownership = "ObjectWriter" +} diff --git a/infrastructure/stacks/deployment-pipelines/main.tf b/infrastructure/stacks/development-and-deployment-tools/github.tf similarity index 53% rename from infrastructure/stacks/deployment-pipelines/main.tf rename to infrastructure/stacks/development-and-deployment-tools/github.tf index 8a0913ee0..1547999fc 100644 --- a/infrastructure/stacks/deployment-pipelines/main.tf +++ b/infrastructure/stacks/development-and-deployment-tools/github.tf @@ -1,3 +1,8 @@ +resource "aws_codestarconnections_connection" "github" { + name = "${var.project_id}-${var.environment}" + provider_type = "GitHub" +} + resource "aws_codebuild_source_credential" "github_authenication" { auth_type = "PERSONAL_ACCESS_TOKEN" server_type = "GITHUB" diff --git a/infrastructure/stacks/development-and-deployment-tools/locals.tf b/infrastructure/stacks/development-and-deployment-tools/locals.tf new file mode 100644 index 000000000..c8d161f44 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/locals.tf @@ -0,0 +1,50 @@ +locals { + development_nonprod_environments = { + # Environments to deploy in the nonprod account from the development pipeline + "test" = { + "PROFILE" = "dev", + "ENVIRONMENT" = "test", + "AWS_ACCOUNT" = "NONPROD" + } + } + development_prod_environments = { + # Environments to deploy in the prod account from the development pipeline + "test" = { + "PROFILE" = "demo", + "ENVIRONMENT" = "demo", + "AWS_ACCOUNT" = "PROD" + } + } + cicd_nonprod_environments = { + # Environments to deploy in the nonprod account from the cicd pipeline + "cicd" = { + "PROFILE" = "dev", + "SHARED_ENVIRONMENT" = "cicd-test", + "AWS_ACCOUNT" = "NONPROD" + } + } + cicd_prod_environments = { + # Environments to deploy in the prod account from the cicd pipeline + "cicd" = { + "PROFILE" = "demo", + "SHARED_ENVIRONMENT" = "cicd-release", + "AWS_ACCOUNT" = "PROD" + } + } + to_build = toset(["service-sync", "service-matcher", "change-event-dlq-handler", "dos-db-update-dlq-handler", "dos-db-handler", "event-replay", "slack-messenger", "send-email", "ingest-change-event", "quality-checker"]) + integration_test_tags = toset(["general", "validation", "slack_and_infrastructure", "reporting", "opening_times"]) + independent_build_images = { + tester = { + "filematch" = "requirement" + } + } + default_environment_variables = { + "AWS_DEFAULT_REGION" = var.aws_region + "AWS_ACCOUNT_ID_LIVE_PARENT" = var.aws_account_id_live_parent + "AWS_ACCOUNT_ID_MGMT" = var.aws_account_id_mgmt + "AWS_ACCOUNT_ID_NONPROD" = var.aws_account_id_nonprod + "AWS_ACCOUNT_ID_IDENTITIES" = var.aws_account_id_identities + "AWS_ACCOUNT_ID_PROD" = var.aws_account_id_prod + "PIPELINE_BUILD_ROLE" = "UECDoSINTPipelineBuildRole" + } +} diff --git a/infrastructure/stacks/development-and-deployment-tools/notify.tf b/infrastructure/stacks/development-and-deployment-tools/notify.tf new file mode 100644 index 000000000..9c82dee8d --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/notify.tf @@ -0,0 +1,44 @@ +#tfsec:ignore:aws-sns-topic-encryption-use-cmk +resource "aws_sns_topic" "pipeline_notification_topic" { + name = var.pipeline_topic_name + kms_master_key_id = "alias/aws/sns" +} + +resource "aws_codestarnotifications_notification_rule" "development_pipeline_notfication_rule" { + detail_type = "BASIC" + event_type_ids = ["codepipeline-pipeline-pipeline-execution-started", "codepipeline-pipeline-pipeline-execution-failed", "codepipeline-pipeline-pipeline-execution-succeeded"] + + name = var.pipeline_notification_name + resource = aws_codepipeline.development_pipeline.arn + + target { + type = "AWSChatbotSlack" + address = "arn:aws:chatbot::${var.aws_account_id_mgmt}:chat-configuration/slack-channel/${var.pipeline_chatbot_channel}" + } +} + +resource "aws_codestarnotifications_notification_rule" "cicd_blue_green_deployment_pipeline" { + detail_type = "BASIC" + event_type_ids = ["codepipeline-pipeline-pipeline-execution-started", "codepipeline-pipeline-pipeline-execution-failed", "codepipeline-pipeline-pipeline-execution-succeeded"] + + name = var.cicd_blue_green_deployment_pipeline_nofitication_name + resource = aws_codepipeline.cicd_blue_green_deployment_pipeline.arn + + target { + type = "AWSChatbotSlack" + address = "arn:aws:chatbot::${var.aws_account_id_mgmt}:chat-configuration/slack-channel/${var.pipeline_chatbot_channel}" + } +} + +resource "aws_codestarnotifications_notification_rule" "cicd_shared_deployment_pipeline" { + detail_type = "BASIC" + event_type_ids = ["codepipeline-pipeline-pipeline-execution-started", "codepipeline-pipeline-pipeline-execution-failed", "codepipeline-pipeline-pipeline-execution-succeeded"] + + name = var.cicd_shared_resources_deployment_pipeline_nofitication_name + resource = aws_codepipeline.cicd_shared_resources_deployment_pipeline.arn + + target { + type = "AWSChatbotSlack" + address = "arn:aws:chatbot::${var.aws_account_id_mgmt}:chat-configuration/slack-channel/${var.pipeline_chatbot_channel}" + } +} diff --git a/infrastructure/stacks/development-and-deployment-tools/parameter_store.tf b/infrastructure/stacks/development-and-deployment-tools/parameter_store.tf new file mode 100644 index 000000000..b307a35e7 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/parameter_store.tf @@ -0,0 +1,12 @@ +resource "aws_ssm_parameter" "blue_green_deployment_new_version" { + #checkov:skip=CKV2_AWS_34:Value does not contain sensitive data so it is ok to be stored in plain text + name = var.blue_green_deployment_new_version_parameter_name + description = "The new version of the application to be deployed to the blue environment" + type = "String" + insecure_value = "NA" + lifecycle { + ignore_changes = [insecure_value] + } + # insecure_value is updated by the blue-green deployment pipeline process + # and should not be updated by Terraform unless necessary +} diff --git a/infrastructure/stacks/development-and-deployment-tools/pipeline_stages.tf b/infrastructure/stacks/development-and-deployment-tools/pipeline_stages.tf new file mode 100644 index 000000000..738472ddd --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/pipeline_stages.tf @@ -0,0 +1,377 @@ +resource "aws_codebuild_project" "unit_tests_stage" { + name = "${var.project_id}-${var.environment}-unit-test-stage" + description = "Runs the unit tests for the DI Project" + build_timeout = "5" + queued_timeout = "5" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "CODEPIPELINE" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + + environment_variable { + name = "TESTER_BUILD_PROJECT_NAME" + value = "${var.project_id}-${var.environment}-build-tester-stage" + } + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-unit-test-stage" + stream_name = "" + } + } + source { + type = "CODEPIPELINE" + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/unit-tests-buildspec.yml" + } +} + +resource "aws_codebuild_project" "build_image_stage" { + name = "${var.project_id}-${var.environment}-build-image-stage" + description = "Builds docker container image" + build_timeout = "15" + queued_timeout = "5" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "CODEPIPELINE" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-aarch64-standard:3.0" + type = "ARM_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-build-stage" + stream_name = "" + } + } + source { + type = "CODEPIPELINE" + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/build-arm-image-in-pipeline-buildspec.yml" + } +} + +resource "aws_codebuild_project" "full_deploy_stage" { + name = "${var.project_id}-${var.environment}-deploy-stage" + description = "Deploy a full DI environment" + build_timeout = "30" + queued_timeout = "60" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "CODEPIPELINE" + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + # Requires - PROFILE, ENVIRONMENT and AWS_ACCOUNT to be set + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-deploy-stage" + stream_name = "" + } + } + source { + type = "CODEPIPELINE" + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-full-environment-buildspec.yml" + } +} + +resource "aws_codebuild_project" "deploy_blue_green_environment_stage" { + name = "${var.project_id}-${var.environment}-deploy-blue-green-environment-stage" + description = "Deploy a blue/green environment" + build_timeout = "30" + queued_timeout = "10" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "CODEPIPELINE" + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + # Requires - PROFILE, ENVIRONMENT and AWS_ACCOUNT to be set + environment_variable { + name = "GIT_REPO_URL" + value = var.github_url + } + + environment_variable { + name = "DELETE_BLUE_GREEN_ENVIRONMENT_CODEBUILD_NAME" + value = aws_codebuild_project.delete_blue_green_environment.name + } + + environment_variable { + name = "NEW_VERSION_PARAMETER_NAME" + value = var.blue_green_deployment_new_version_parameter_name + } + + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-deploy-stage" + stream_name = "" + } + } + source { + type = "CODEPIPELINE" + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-blue-green-environment-buildspec.yml" + } + depends_on = [ + aws_codebuild_project.delete_blue_green_environment + ] +} + +resource "aws_codebuild_project" "deploy_shared_resources_environment_stage" { + name = "${var.project_id}-${var.environment}-deploy-shared-resources-environment-stage" + description = "Deploy a shared resources environment" + build_timeout = "30" + queued_timeout = "10" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "CODEPIPELINE" + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + # Requires - PROFILE, ENVIRONMENT and AWS_ACCOUNT to be set + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-deploy-shared-resources-environment-stage" + stream_name = "" + } + } + source { + type = "CODEPIPELINE" + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/deploy-shared-resources-environment-buildspec.yml" + } +} + +resource "aws_codebuild_project" "integration_tests" { + for_each = local.integration_test_tags + name = "${var.project_id}-${var.environment}-integration-tests-${each.key}" + description = "Runs the integration tests for the DI Project" + build_timeout = "60" + queued_timeout = "10" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "CODEPIPELINE" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] + } + + environment { + compute_type = "BUILD_GENERAL1_LARGE" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + environment_variable { + name = "TAG" + value = each.key + } + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-integration-test-stage" + stream_name = "" + } + } + source { + type = "CODEPIPELINE" + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/integration-tests-buildspec.yml" + } +} + + +resource "aws_codebuild_project" "trigger_rollback" { + name = "${var.project_id}-${var.environment}-trigger-rollback" + description = "Trigger rollback" + build_timeout = "30" + queued_timeout = "5" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "CODEPIPELINE" + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + + environment_variable { + name = "GIT_REPO_URL" + value = var.github_url + } + environment_variable { + name = "PROJECT_REPO" + value = "${var.github_owner}/${var.github_repo}" + } + environment_variable { + name = "ROLLBACK_PROJECT_NAME" + value = aws_codebuild_project.blue_green_rollback_stage.name + } + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-trigger-rollback" + stream_name = "" + } + } + source { + type = "CODEPIPELINE" + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/trigger-rollback-buildspec.yml" + } +} + +resource "aws_codebuild_project" "production_smoke_test" { + name = "${var.project_id}-${var.environment}-production-smoke-test" + description = "Production smoke test" + build_timeout = "30" + queued_timeout = "5" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "CODEPIPELINE" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-production-smoke-test" + stream_name = "" + } + } + source { + type = "CODEPIPELINE" + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/production-smoke-test-buildspec.yml" + } +} diff --git a/infrastructure/stacks/development-and-deployment-tools/security_groups.tf b/infrastructure/stacks/development-and-deployment-tools/security_groups.tf new file mode 100644 index 000000000..b8bcf1b56 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/security_groups.tf @@ -0,0 +1,16 @@ +resource "aws_security_group" "codebuild_sg" { + vpc_id = data.aws_vpc.texas_mgmt_vpc.id + name = "${var.project_id}-${var.environment}-codebuild-sg" + description = "Codebuild security group for accessing Jenkins" +} + +#tfsec:ignore:aws-vpc-no-public-egress-sgr +resource "aws_security_group_rule" "allow_https_out" { + type = "egress" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.codebuild_sg.id + description = "Lets out HTTPS traffic to access Jenkins" +} diff --git a/infrastructure/stacks/development-and-deployment-tools/setup_dos_environment.tf b/infrastructure/stacks/development-and-deployment-tools/setup_dos_environment.tf new file mode 100644 index 000000000..a62efaa49 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/setup_dos_environment.tf @@ -0,0 +1,78 @@ +resource "aws_cloudwatch_event_rule" "setup_dos_environment_rule" { + name = "${var.project_id}-${var.environment}-setup-dos-environment-rule" + description = "Trigger the setup of a DOS environment on a schedule" + schedule_expression = "cron(0 1 ? * MON-FRI *)" +} + +resource "aws_cloudwatch_event_target" "setup_dos_environment_trigger" { + rule = aws_cloudwatch_event_rule.setup_dos_environment_rule.name + arn = aws_codebuild_project.setup_dos_environment.arn + role_arn = data.aws_iam_role.pipeline_role.arn +} + +resource "aws_codebuild_project" "setup_dos_environment" { + name = "${var.project_id}-${var.environment}-setup-dos-environment-stage" + description = "Setup the DoS RegressionDI Environment" + build_timeout = "60" + queued_timeout = "30" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "NO_ARTIFACTS" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] + } + + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + environment_variable { + name = "PROFILE" + value = "dev" + } + environment_variable { + name = "ENVIRONMENT" + value = "test" + } + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + } + + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-setup-dos-environment-stage" + stream_name = "" + } + } + + source { + type = "GITHUB" + git_clone_depth = 0 + location = var.github_url + buildspec = "infrastructure/stacks/development-and-deployment-tools/buildspecs/setup-dos-environment-buildspec.yml" + } + vpc_config { + security_group_ids = [ + aws_security_group.codebuild_sg.id, + ] + subnets = [ + data.aws_subnet.vpc_subnet_one.id, + data.aws_subnet.vpc_subnet_two.id, + data.aws_subnet.vpc_subnet_three.id + ] + vpc_id = data.aws_vpc.texas_mgmt_vpc.id + } +} diff --git a/infrastructure/stacks/development-and-deployment-tools/task_env_deploy_and_test.tf b/infrastructure/stacks/development-and-deployment-tools/task_env_deploy_and_test.tf new file mode 100644 index 000000000..0d2c8e5f4 --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/task_env_deploy_and_test.tf @@ -0,0 +1,75 @@ +resource "aws_codebuild_webhook" "task_env_deploy_and_test_webhook" { + project_name = aws_codebuild_project.task_env_deploy_and_test.name + build_type = "BUILD_BATCH" + filter_group { + filter { + type = "EVENT" + pattern = "PUSH" + } + filter { + type = "HEAD_REF" + pattern = "refs/heads/task/DS-[0-9]*" + } + } + depends_on = [aws_codebuild_project.task_env_deploy_and_test] +} + + +resource "aws_codebuild_project" "task_env_deploy_and_test" { + name = "${var.project_id}-${var.environment}-task-env-deploy-and-test" + description = "Builds, deploys and tests task environment, triggered by a push to a task branch" + build_timeout = "90" + queued_timeout = "5" + service_role = data.aws_iam_role.pipeline_role.arn + + artifacts { + type = "NO_ARTIFACTS" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_DOCKER_LAYER_CACHE"] + } + + build_batch_config { + service_role = data.aws_iam_role.pipeline_role.arn + timeout_in_mins = 60 + restrictions { + compute_types_allowed = [] + maximum_builds_allowed = 100 + } + } + + environment { + compute_type = "BUILD_GENERAL1_SMALL" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + environment_variable { + name = "CB_PROJECT_NAME" + value = "${var.project_id}-${var.environment}-task-env-deploy-and-test" + } + dynamic "environment_variable" { + for_each = local.default_environment_variables + content { + name = environment_variable.key + value = environment_variable.value + } + } + + } + logs_config { + cloudwatch_logs { + group_name = "/aws/codebuild/${var.project_id}-${var.environment}-task-env-deploy-and-test" + stream_name = "" + } + } + source { + type = "GITHUB" + git_clone_depth = 0 + location = var.github_url + buildspec = "infrastructure/stacks/development-and-deployment-tools/batch-buildspecs/task-env-deploy-and-test-buildspec.yml" + } +} diff --git a/infrastructure/stacks/development-and-deployment-tools/terraform.tf b/infrastructure/stacks/development-and-deployment-tools/terraform.tf new file mode 100644 index 000000000..dd732c88d --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/terraform.tf @@ -0,0 +1,15 @@ +terraform { + backend "s3" { + encrypt = true + } + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.32.1" + } + template = { + source = "hashicorp/template" + version = "~> 2.2.0" + } + } +} diff --git a/infrastructure/stacks/development-and-deployment-tools/variables.tf b/infrastructure/stacks/development-and-deployment-tools/variables.tf new file mode 100644 index 000000000..2c553bf2c --- /dev/null +++ b/infrastructure/stacks/development-and-deployment-tools/variables.tf @@ -0,0 +1,105 @@ +# ############## +# # PIPELINE VARIABLES +# ############## +variable "development_pipeline_branch_name" { + type = string + description = "Branch name for the development pipeline to track" +} + +variable "cicd_blue_green_deployment_pipeline_artefact_bucket" { + type = string + description = "The name of the S3 bucket where the artefacts are stored for the blue-green deployment pipeline" +} + +variable "cicd_shared_resoures_deployment_pipeline_artefact_bucket" { + type = string + description = "The name of the S3 bucket where the artefacts are stored for the shared resources deployment pipeline" +} + +variable "cicd_blue_green_deployment_pipeline_name" { + type = string + description = "The name of the blue/green deployment pipeline" +} + +variable "cicd_shared_resources_deployment_pipeline_name" { + type = string + description = "The name of the shared resources deployment pipeline" +} + +variable "pipeline_notification_name" { + type = string + description = "Development pipeline notification name" +} + +variable "cicd_blue_green_deployment_pipeline_nofitication_name" { + type = string + description = "Blue-green deployment pipeline notification name" +} + +variable "cicd_shared_resources_deployment_pipeline_nofitication_name" { + type = string + description = "Shared resources deployment pipeline notification name" +} + +variable "pipeline_chatbot_channel" { + type = string + description = "" +} + +variable "pipeline_topic_name" { + type = string + description = "" +} + +variable "github_owner" { + type = string + description = "" +} + +variable "github_repo" { + type = string + description = "" +} + +variable "github_url" { + type = string + description = "The URL of the DoS Integration GitHub repository" +} + +variable "mgmt_vpc_name" { + type = string + description = "Name of the management VPC" +} + +# ############## +# # PARAMETER STORE +# ############## + +variable "blue_green_deployment_new_version_parameter_name" { + type = string + description = "The name of the parameter in the parameter store that stores the new version of the blue/green deployment" +} + +# ############## +# # OTHER +# ############## + +variable "developer_role" { + type = string + description = "The name of the developer role" +} + + +variable "github_token" { + type = string + description = "GitHub token for the CodeBuild projects" +} + +# ############## +# # KMS +# ############## + +variable "development_tools_encryption_key_alias" { + type = string + description = "The alias of the KMS key used to encrypt the development tools" +} diff --git a/infrastructure/stacks/development-pipeline/.terraform.lock.hcl b/infrastructure/stacks/development-pipeline/.terraform.lock.hcl deleted file mode 100644 index 86245e694..000000000 --- a/infrastructure/stacks/development-pipeline/.terraform.lock.hcl +++ /dev/null @@ -1,38 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "3.75.0" - constraints = "~> 3.69" - hashes = [ - "h1:TgB2G/9/RePrVXq2iGo+/BCCmUQK2nRuvTeAa1geiIk=", - "zh:082cf60b79abb20ca1b8314bf88c1e00d79aa7534dc7cfc4434d96dee82c2ed6", - "zh:11ea608d2c6757df8b76af50a25d5b050f029ed32dcbea1e2477e3701d73f8d5", - "zh:3ff1ca4fa5033cf8ecbe64482c22009196882ec8cf74aa107389690839cfe7fa", - "zh:a8f9be2c5d51fa1d403c725fa36f50b20edb9b2ecd8790ebd47c368bca00409c", - "zh:cc06e330729aa092ad1eae44378fced41d17f0a108ad6f12479e325c37ff35a4", - "zh:cdfe9a812f2b0587dad24b1b19fb3af2f236aa228ea30436cf7c1dbfd9f43880", - "zh:d208e30bdcdd7b1326a1b31def46f94664cd04b67414e51e20f9bad24c196da9", - "zh:e447700ef2402db311392d494a97d122584f71c31d8f13e0af781e88beac73c5", - "zh:f819e78936bbcc8c16be43c94576bc5b31e2f2a3e7a164c45a70ec8efc336071", - "zh:f86b18bdc722c80b017a960a0c93adf9370c93f67ce0dc9505e38d1d20896d5e", - "zh:fa659d043a2386834b448de31cd6513a11065a9be59a07c31cd61f3f9fe7c6d5", - ] -} - -provider "registry.terraform.io/hashicorp/template" { - version = "2.2.0" - hashes = [ - "h1:94qn780bi1qjrbC3uQtjJh3Wkfwd5+tTtJHOb7KTg9w=", - "zh:01702196f0a0492ec07917db7aaa595843d8f171dc195f4c988d2ffca2a06386", - "zh:09aae3da826ba3d7df69efeb25d146a1de0d03e951d35019a0f80e4f58c89b53", - "zh:09ba83c0625b6fe0a954da6fbd0c355ac0b7f07f86c91a2a97849140fea49603", - "zh:0e3a6c8e16f17f19010accd0844187d524580d9fdb0731f675ffcf4afba03d16", - "zh:45f2c594b6f2f34ea663704cc72048b212fe7d16fb4cfd959365fa997228a776", - "zh:77ea3e5a0446784d77114b5e851c970a3dde1e08fa6de38210b8385d7605d451", - "zh:8a154388f3708e3df5a69122a23bdfaf760a523788a5081976b3d5616f7d30ae", - "zh:992843002f2db5a11e626b3fc23dc0c87ad3729b3b3cff08e32ffb3df97edbde", - "zh:ad906f4cebd3ec5e43d5cd6dc8f4c5c9cc3b33d2243c89c5fc18f97f7277b51d", - "zh:c979425ddb256511137ecd093e23283234da0154b7fa8b21c2687182d9aea8b2", - ] -} diff --git a/infrastructure/stacks/development-pipeline/build-environment-buildspec.yml b/infrastructure/stacks/development-pipeline/build-environment-buildspec.yml deleted file mode 100644 index 4f582615f..000000000 --- a/infrastructure/stacks/development-pipeline/build-environment-buildspec.yml +++ /dev/null @@ -1,29 +0,0 @@ -# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html -version: 0.2 - -phases: - pre_build: - commands: - - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/UECPUPipelineBuildRole" --role-session-name "CodeBuildSession") - - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) - - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) - - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) - - make docker-hub-signin - build: - commands: - - export BUILD_BRANCH=$(echo $CODEBUILD_WEBHOOK_TRIGGER | sed -e "s|^refs/heads/||") - - echo Branch = $BUILD_BRANCH - - if [ -n "$BUILD_BRANCH" ]; then - make build-and-deploy BUILD_BRANCH=$BUILD_BRANCH PROFILE=$PROFILE DOCKER_BUILDKIT=1 ; - fi - finally: - - | - if [ -n "$BUILD_BRANCH" ]; then - if [ $CODEBUILD_BUILD_SUCCEEDING -eq 0 ]; then - make slack-codebuild-notification BUILD_BRANCH=$BUILD_BRANCH PROFILE=$PROFILE PIPELINE_NAME="Build and Deploy Codebuild Stage" BUILD_STATUS=failure CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Build Failed Please investigate" - else - make slack-codebuild-notification BUILD_BRANCH=$BUILD_BRANCH PROFILE=$PROFILE PIPELINE_NAME="Build and Deploy Codebuild Stage" BUILD_STATUS=success CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Build Succeeded" - fi - else - make slack-codebuild-notification BUILD_BRANCH=$BUILD_BRANCH PROFILE=$PROFILE PIPELINE_NAME="Build and Deploy Codebuild Stage" BUILD_STATUS=failure CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Build Failed due to incorrect naming of branch. Please check the branch name." - fi diff --git a/infrastructure/stacks/development-pipeline/build-image-buildspec.yml b/infrastructure/stacks/development-pipeline/build-image-buildspec.yml deleted file mode 100644 index c70245265..000000000 --- a/infrastructure/stacks/development-pipeline/build-image-buildspec.yml +++ /dev/null @@ -1,25 +0,0 @@ -# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html -version: 0.2 - -phases: - pre_build: - commands: - - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/UECPUPipelineBuildRole" --role-session-name "CodeBuildSession") - - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) - - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) - - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) - - make docker-hub-signin - build: - commands: - - echo "[Building docker containers]" - - make $BUILD_TARGET VERSION=$CODEBUILD_RESOLVED_SOURCE_VERSION - - echo "[Pushing images to ECR]" - - unset AWS_ACCESS_KEY_ID - - unset AWS_SECRET_ACCESS_KEY - - unset AWS_SESSION_TOKEN - - make docker-push NAME=$BUILD_ITEM_NAME - finally: - - | - if [ $CODEBUILD_BUILD_SUCCEEDING -eq 0 ]; then - make slack-codebuild-notification PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT PIPELINE_NAME="Build $BUILD_ITEM_NAME Image" BUILD_STATUS=failure CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Failed Please investigate" - fi diff --git a/infrastructure/stacks/development-pipeline/build_environment.tf b/infrastructure/stacks/development-pipeline/build_environment.tf deleted file mode 100644 index fafcc485f..000000000 --- a/infrastructure/stacks/development-pipeline/build_environment.tf +++ /dev/null @@ -1,86 +0,0 @@ -resource "aws_codebuild_webhook" "build_environment_webhook" { - count = var.environment == "dev" ? 1 : 0 - project_name = aws_codebuild_project.di_build_environment[0].name - build_type = "BUILD" - filter_group { - filter { - type = "EVENT" - pattern = "PUSH" - } - filter { - type = "HEAD_REF" - pattern = "refs/heads/task/DI-[0-9]*" - } - } - depends_on = [aws_codebuild_project.di_build_environment] -} - -resource "aws_codebuild_project" "di_build_environment" { - count = var.environment == "dev" ? 1 : 0 - name = "${var.project_id}-${var.environment}-build-environment-stage" - description = "Builds environment based on push to task branches" - build_timeout = "30" - queued_timeout = "5" - service_role = data.aws_iam_role.pipeline_role.arn - - artifacts { - type = "NO_ARTIFACTS" - } - - cache { - type = "LOCAL" - modes = ["LOCAL_DOCKER_LAYER_CACHE"] - } - - - environment { - compute_type = "BUILD_GENERAL1_LARGE" - image = "aws/codebuild/amazonlinux2-x86_64-standard:3.0" - type = "LINUX_CONTAINER" - image_pull_credentials_type = "CODEBUILD" - privileged_mode = true - - environment_variable { - name = "PROFILE" - value = "task" - } - environment_variable { - name = "CB_PROJECT_NAME" - value = "${var.project_id}-${var.environment}-build-environment-stage" - } - - environment_variable { - name = "AWS_ACCOUNT_ID_LIVE_PARENT" - value = var.aws_account_id_live_parent - } - environment_variable { - name = "AWS_ACCOUNT_ID_MGMT" - value = var.aws_account_id_mgmt - } - environment_variable { - name = "AWS_ACCOUNT_ID_NONPROD" - value = var.aws_account_id_nonprod - } - environment_variable { - name = "AWS_ACCOUNT_ID_PROD" - value = var.aws_account_id_prod - } - environment_variable { - name = "AWS_ACCOUNT_ID_IDENTITIES" - value = var.aws_account_id_identities - } - } - logs_config { - cloudwatch_logs { - group_name = "/aws/codebuild/${var.project_id}-${var.environment}-build-environment-stage" - stream_name = "" - } - } - source { - type = "GITHUB" - git_clone_depth = 0 - location = "https://github.com/nhsd-exeter/dos-integration.git" - buildspec = data.template_file.build_environment_buildspec.rendered - } - -} diff --git a/infrastructure/stacks/development-pipeline/codebuild.tf b/infrastructure/stacks/development-pipeline/codebuild.tf deleted file mode 100644 index ce38eb05c..000000000 --- a/infrastructure/stacks/development-pipeline/codebuild.tf +++ /dev/null @@ -1,353 +0,0 @@ -resource "aws_codebuild_project" "di_unit_tests" { - name = "${var.project_id}-${var.environment}-unit-test-stage" - description = "Runs the unit tests for the DI Project" - build_timeout = "5" - queued_timeout = "5" - service_role = data.aws_iam_role.pipeline_role.arn - - artifacts { - type = "CODEPIPELINE" - } - - cache { - type = "LOCAL" - modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] - } - - environment { - compute_type = "BUILD_GENERAL1_SMALL" - image = "${var.aws_account_id_mgmt}.dkr.ecr.eu-west-2.amazonaws.com/uec-dos/int/tester:latest" - type = "LINUX_CONTAINER" - image_pull_credentials_type = "SERVICE_ROLE" - privileged_mode = true - - environment_variable { - name = "PROFILE" - value = "local" - } - environment_variable { - name = "ENVIRONMENT" - value = var.environment - } - environment_variable { - name = "AWS_ACCOUNT_ID_LIVE_PARENT" - value = var.aws_account_id_live_parent - } - environment_variable { - name = "AWS_ACCOUNT_ID_MGMT" - value = var.aws_account_id_mgmt - } - environment_variable { - name = "AWS_ACCOUNT_ID_NONPROD" - value = var.aws_account_id_nonprod - } - environment_variable { - name = "AWS_ACCOUNT_ID_PROD" - value = var.aws_account_id_prod - } - environment_variable { - name = "AWS_ACCOUNT_ID_IDENTITIES" - value = var.aws_account_id_identities - } - environment_variable { - name = "TESTER_BUILD_PROJECT_NAME" - value = "${var.project_id}-${var.environment}-build-tester-stage" - } - } - - logs_config { - cloudwatch_logs { - group_name = "/aws/codebuild/${var.project_id}-${var.environment}-unit-test-stage" - stream_name = "" - } - } - source { - type = "CODEPIPELINE" - buildspec = data.template_file.unit_tests_buildspec.rendered - } -} - -resource "aws_codebuild_project" "di_build" { - - for_each = local.to_build - name = "${var.project_id}-${var.environment}-build-${each.key}-stage" - description = "Builds ${each.key} docker container image" - build_timeout = "5" - queued_timeout = "5" - service_role = data.aws_iam_role.pipeline_role.arn - - artifacts { - type = "CODEPIPELINE" - } - - cache { - type = "LOCAL" - modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] - } - - - environment { - compute_type = "BUILD_GENERAL1_SMALL" - image = "aws/codebuild/amazonlinux2-x86_64-standard:3.0" - type = "LINUX_CONTAINER" - image_pull_credentials_type = "CODEBUILD" - privileged_mode = true - - environment_variable { - name = "PROFILE" - value = "local" - } - environment_variable { - name = "BUILD_TARGET" - value = "${each.key}-build" - } - environment_variable { - name = "BUILD_ITEM_NAME" - value = each.key - } - environment_variable { - name = "ENVIRONMENT" - value = var.environment - } - environment_variable { - name = "AWS_ACCOUNT_ID_LIVE_PARENT" - value = var.aws_account_id_live_parent - } - environment_variable { - name = "AWS_ACCOUNT_ID_MGMT" - value = var.aws_account_id_mgmt - } - environment_variable { - name = "AWS_ACCOUNT_ID_NONPROD" - value = var.aws_account_id_nonprod - } - environment_variable { - name = "AWS_ACCOUNT_ID_PROD" - value = var.aws_account_id_prod - } - environment_variable { - name = "AWS_ACCOUNT_ID_IDENTITIES" - value = var.aws_account_id_identities - } - } - - logs_config { - cloudwatch_logs { - group_name = "/aws/codebuild/${var.project_id}-${var.environment}-build-stage" - stream_name = "" - } - } - source { - type = "CODEPIPELINE" - buildspec = data.template_file.build_buildspec.rendered - } -} - -resource "aws_codebuild_project" "di_deploy_dev" { - for_each = local.deploy_envs - name = "${var.project_id}-${var.environment}-deploy-${each.key}-stage" - description = "Deploy to the ${each.key} environment" - build_timeout = "10" - queued_timeout = "30" - service_role = data.aws_iam_role.pipeline_role.arn - - artifacts { - type = "CODEPIPELINE" - } - - cache { - type = "LOCAL" - modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] - } - - - environment { - compute_type = "BUILD_GENERAL1_SMALL" - image = "aws/codebuild/amazonlinux2-x86_64-standard:3.0" - type = "LINUX_CONTAINER" - image_pull_credentials_type = "CODEBUILD" - privileged_mode = true - - environment_variable { - name = "PROFILE" - value = each.key - } - environment_variable { - name = "ENVIRONMENT" - value = each.key - } - environment_variable { - name = "AWS_ACCOUNT_ID_LIVE_PARENT" - value = var.aws_account_id_live_parent - } - environment_variable { - name = "AWS_ACCOUNT_ID_MGMT" - value = var.aws_account_id_mgmt - } - environment_variable { - name = "AWS_ACCOUNT_ID_NONPROD" - value = var.aws_account_id_nonprod - } - environment_variable { - name = "AWS_ACCOUNT_ID_PROD" - value = var.aws_account_id_prod - } - environment_variable { - name = "AWS_ACCOUNT_ID_IDENTITIES" - value = var.aws_account_id_identities - } - environment_variable { - name = "SERVERLESS_BUILD_PROJECT_NAME" - value = "${var.project_id}-${var.environment}-build-serverless-stage" - } - } - - logs_config { - cloudwatch_logs { - group_name = "/aws/codebuild/${var.project_id}-${var.environment}-deploy-${each.key}-stage" - stream_name = "" - } - } - source { - type = "CODEPIPELINE" - buildspec = data.template_file.deploy_buildspec.rendered - } -} - -resource "aws_codebuild_project" "di_integration_tests" { - for_each = local.integration_tags - name = "${var.project_id}-${var.environment}-integration-test-stage-${each.key}" - description = "Runs the integration tests for the DI Project" - build_timeout = "60" - queued_timeout = "30" - service_role = data.aws_iam_role.pipeline_role.arn - - artifacts { - type = "CODEPIPELINE" - } - - cache { - type = "LOCAL" - modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] - } - - - environment { - compute_type = "BUILD_GENERAL1_LARGE" - image = "aws/codebuild/amazonlinux2-x86_64-standard:3.0" - type = "LINUX_CONTAINER" - image_pull_credentials_type = "CODEBUILD" - privileged_mode = true - - environment_variable { - name = "PROFILE" - value = "test" - } - environment_variable { - name = "ENVIRONMENT" - value = "test" - } - environment_variable { - name = "INTEGRATION_TAGS" - value = each.key - } - environment_variable { - name = "AWS_ACCOUNT_ID_LIVE_PARENT" - value = var.aws_account_id_live_parent - } - environment_variable { - name = "AWS_ACCOUNT_ID_MGMT" - value = var.aws_account_id_mgmt - } - environment_variable { - name = "AWS_ACCOUNT_ID_NONPROD" - value = var.aws_account_id_nonprod - } - environment_variable { - name = "AWS_ACCOUNT_ID_PROD" - value = var.aws_account_id_prod - } - environment_variable { - name = "AWS_ACCOUNT_ID_IDENTITIES" - value = var.aws_account_id_identities - } - } - - logs_config { - cloudwatch_logs { - group_name = "/aws/codebuild/${var.project_id}-${var.environment}-integration-test-stage" - stream_name = "" - } - } - source { - type = "CODEPIPELINE" - buildspec = data.template_file.integration_tests_buildspec.rendered - } -} - - -resource "aws_codebuild_project" "di_deploy_fresh" { - name = "${var.project_id}-${var.environment}-deploy-fresh-stage" - description = "Deploy to the fresh environment" - build_timeout = "50" - queued_timeout = "30" - service_role = data.aws_iam_role.pipeline_role.arn - - artifacts { - type = "CODEPIPELINE" - } - - cache { - type = "LOCAL" - modes = ["LOCAL_SOURCE_CACHE", "LOCAL_DOCKER_LAYER_CACHE"] - } - - - environment { - compute_type = "BUILD_GENERAL1_SMALL" - image = "aws/codebuild/amazonlinux2-x86_64-standard:3.0" - type = "LINUX_CONTAINER" - image_pull_credentials_type = "CODEBUILD" - privileged_mode = true - - environment_variable { - name = "PROFILE" - value = "dev" - } - environment_variable { - name = "ENVIRONMENT" - value = "fresh" - } - environment_variable { - name = "AWS_ACCOUNT_ID_LIVE_PARENT" - value = var.aws_account_id_live_parent - } - environment_variable { - name = "AWS_ACCOUNT_ID_MGMT" - value = var.aws_account_id_mgmt - } - environment_variable { - name = "AWS_ACCOUNT_ID_NONPROD" - value = var.aws_account_id_nonprod - } - environment_variable { - name = "AWS_ACCOUNT_ID_PROD" - value = var.aws_account_id_prod - } - environment_variable { - name = "AWS_ACCOUNT_ID_IDENTITIES" - value = var.aws_account_id_identities - } - } - - logs_config { - cloudwatch_logs { - group_name = "/aws/codebuild/${var.project_id}-${var.environment}-deploy-fresh-stage" - stream_name = "" - } - } - source { - type = "CODEPIPELINE" - buildspec = data.template_file.deploy_buildspec.rendered - } -} diff --git a/infrastructure/stacks/development-pipeline/codepipeline.tf b/infrastructure/stacks/development-pipeline/codepipeline.tf deleted file mode 100644 index d8c5bf510..000000000 --- a/infrastructure/stacks/development-pipeline/codepipeline.tf +++ /dev/null @@ -1,125 +0,0 @@ -resource "aws_codepipeline" "codepipeline" { - name = "${var.project_id}-${var.environment}-codepipeline" - role_arn = data.aws_iam_role.pipeline_role.arn - - artifact_store { - location = "${var.project_id}-${var.environment}-codepipeline-artefact-bucket-mgmt" - type = "S3" - } - - - stage { - name = "Source" - action { - category = "Source" - name = "Source" - owner = "AWS" - provider = "CodeStarSourceConnection" - version = "1" - output_artifacts = ["source_output"] - - configuration = { - ConnectionArn = aws_codestarconnections_connection.github.arn - FullRepositoryId = "${var.github_owner}/${var.github_repo}" - BranchName = var.code_pipeline_branch_name - DetectChanges = true - } - } - } - - stage { - name = "UnitTests" - action { - name = "UnitTests" - category = "Build" - owner = "AWS" - provider = "CodeBuild" - input_artifacts = ["source_output"] - version = "1" - configuration = { - ProjectName = "${var.project_id}-${var.environment}-unit-test-stage" - } - } - } - stage { - name = "Build" - dynamic "action" { - for_each = local.to_build - content { - name = "Build_${action.key}" - category = "Build" - owner = "AWS" - provider = "CodeBuild" - input_artifacts = ["source_output"] - version = "1" - configuration = { - ProjectName = "${var.project_id}-${var.environment}-build-${action.key}-stage" - } - } - } - } - stage { - name = "Deploy" - dynamic "action" { - for_each = local.deploy_envs - content { - name = "Deploy_${action.key}" - category = "Build" - owner = "AWS" - run_order = 1 - provider = "CodeBuild" - input_artifacts = ["source_output"] - version = "1" - configuration = { - ProjectName = "${var.project_id}-${var.environment}-deploy-${action.key}-stage" - } - } - } - # action { - # name = "Deploy_fresh" - # category = "Build" - # owner = "AWS" - # run_order = 1 - # provider = "CodeBuild" - # input_artifacts = ["source_output"] - # version = "1" - # configuration = { - # ProjectName = "${var.project_id}-${var.environment}-deploy-fresh-stage" - # } - # } - } - - stage { - name = "Integration_Test" - dynamic "action" { - for_each = local.integration_tags - content { - name = "Integration_Test_${action.key}" - category = "Build" - owner = "AWS" - provider = "CodeBuild" - input_artifacts = ["source_output"] - version = "1" - configuration = { - ProjectName = "${var.project_id}-${var.environment}-integration-test-stage-${action.key}" - } - } - } - } - depends_on = [module.codepipeline_artefact_bucket] -} - -resource "aws_codestarconnections_connection" "github" { - name = "${var.project_id}-codestarconnection" - provider_type = "GitHub" -} - -module "codepipeline_artefact_bucket" { - source = "../../modules/s3" - name = "${var.project_id}-${var.environment}-codepipeline-artefact-bucket-mgmt" - project_id = var.project_id - acl = "private" - profile = var.profile - versioning_enabled = "true" - force_destroy = "true" -} diff --git a/infrastructure/stacks/development-pipeline/data.tf b/infrastructure/stacks/development-pipeline/data.tf deleted file mode 100644 index 2417c9aa5..000000000 --- a/infrastructure/stacks/development-pipeline/data.tf +++ /dev/null @@ -1,53 +0,0 @@ -data "template_file" "unit_tests_buildspec" { - template = file("unit-tests-buildspec.yml") -} - -data "template_file" "build_buildspec" { - template = file("build-buildspec.yml") -} - -data "template_file" "build_image_buildspec" { - template = file("build-image-buildspec.yml") -} - -data "template_file" "deploy_buildspec" { - template = file("deploy-buildspec.yml") -} - -data "template_file" "integration_tests_buildspec" { - template = file("integration-tests-buildspec.yml") -} - -data "template_file" "delete_task_environment_from_tag_buildspec" { - template = file("delete-task-environment-from-tag-buildspec.yml") -} - -data "template_file" "delete_task_environment_on_pr_merged_buildspec" { - template = file("delete-task-environment-on-pr-merged-buildspec.yml") -} - -data "template_file" "build_environment_buildspec" { - template = file("build-environment-buildspec.yml") -} - -data "template_file" "delete_ecr_images_buildspec" { - template = file("delete-ecr-images-buildspec.yml") -} - -data "aws_iam_role" "pipeline_role" { - name = "UECPUPipelineRole" -} - -locals { - deploy_envs = toset(["dev", "test", "perf"]) - to_build = toset(["event-sender", "event-processor", "fifo-dlq-handler", "orchestrator", "cr-fifo-dlq-handler", "test-db-checker-handler", "event-replay", "authoriser", "dos-api-gateway", "slack-messenger"]) - integration_tags = toset(["pharmacy_cloudwatch_queries", "pharmacy_no_log_searches"]) - independent_build_images = { - tester = { - "filematch" = "requirement" - } - serverless = { - "filematch" = "serverless.yml" - } - } -} diff --git a/infrastructure/stacks/development-pipeline/delete-ecr-images-buildspec.yml b/infrastructure/stacks/development-pipeline/delete-ecr-images-buildspec.yml deleted file mode 100644 index 054d7f354..000000000 --- a/infrastructure/stacks/development-pipeline/delete-ecr-images-buildspec.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 0.2 - -phases: - build: - commands: - - temp_role=$(aws sts assume-role --role-arn "arn:aws:iam::$AWS_ACCOUNT_ID_NONPROD:role/UECPUPipelineBuildRole" --role-session-name "CodeBuildSession") - - export AWS_ACCESS_KEY_ID=$(echo $temp_role | jq -r .Credentials.AccessKeyId) - - export AWS_SECRET_ACCESS_KEY=$(echo $temp_role | jq -r .Credentials.SecretAccessKey) - - export AWS_SESSION_TOKEN=$(echo $temp_role | jq -r .Credentials.SessionToken) - - aws sts get-caller-identity - - make aws-ecr-cleanup REPOS=authoriser,cr-fifo-dlq-handler,dos-api-gateway,event-processor,event-replay,event-sender,fifo-dlq-handler,slack-messenger,test-db-checker-handler - finally: - - | - if [ $CODEBUILD_BUILD_SUCCEEDING -eq 0 ]; then - make slack-codebuild-notification PROFILE=$PROFILE ENVIRONMENT=$ENVIRONMENT PIPELINE_NAME="Delete ECR Images" BUILD_STATUS=failure CODEBUILD_PROJECT_NAME=$CB_PROJECT_NAME CODEBUILD_BUILD_ID=$CODEBUILD_BUILD_ID SLACK_MESSAGE="Delete Failed Please investigate" - fi diff --git a/infrastructure/stacks/development-pipeline/delete_ecr_images.tf b/infrastructure/stacks/development-pipeline/delete_ecr_images.tf deleted file mode 100644 index 898f81c7a..000000000 --- a/infrastructure/stacks/development-pipeline/delete_ecr_images.tf +++ /dev/null @@ -1,85 +0,0 @@ -resource "aws_cloudwatch_event_rule" "delete_ecr_images_rule" { - count = var.environment == "dev" ? 1 : 0 - name = "${var.project_id}-${var.environment}-delete-ecr-images-rule" - description = "Delete ECR images on the first of every month" - schedule_expression = "cron(0 0 1 * ? *)" -} - -resource "aws_cloudwatch_event_target" "delete_ecr_images_trigger" { - count = var.environment == "dev" ? 1 : 0 - rule = aws_cloudwatch_event_rule.delete_ecr_images_rule[0].name - arn = aws_codebuild_project.di_delete_ecr_images[0].arn - role_arn = data.aws_iam_role.pipeline_role.arn -} - -resource "aws_codebuild_project" "di_delete_ecr_images" { - count = var.environment == "dev" ? 1 : 0 - name = "${var.project_id}-${var.environment}-delete-ecr-images-stage" - description = "Deletes ECR images" - build_timeout = "30" - queued_timeout = "5" - service_role = data.aws_iam_role.pipeline_role.arn - - artifacts { - type = "NO_ARTIFACTS" - } - - cache { - type = "LOCAL" - modes = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_SOURCE_CACHE"] - } - - - environment { - compute_type = "BUILD_GENERAL1_SMALL" - image = "aws/codebuild/amazonlinux2-x86_64-standard:3.0" - type = "LINUX_CONTAINER" - image_pull_credentials_type = "CODEBUILD" - privileged_mode = true - - environment_variable { - name = "PROFILE" - value = "dev" - } - environment_variable { - name = "ENVIRONMENT" - value = "dev" - } - environment_variable { - name = "CB_PROJECT_NAME" - value = "${var.project_id}-${var.environment}-delete-ecr-images-stage" - } - environment_variable { - name = "AWS_ACCOUNT_ID_LIVE_PARENT" - value = var.aws_account_id_live_parent - } - environment_variable { - name = "AWS_ACCOUNT_ID_MGMT" - value = var.aws_account_id_mgmt - } - environment_variable { - name = "AWS_ACCOUNT_ID_NONPROD" - value = var.aws_account_id_nonprod - } - environment_variable { - name = "AWS_ACCOUNT_ID_PROD" - value = var.aws_account_id_prod - } - environment_variable { - name = "AWS_ACCOUNT_ID_IDENTITIES" - value = var.aws_account_id_identities - } - } - logs_config { - cloudwatch_logs { - group_name = "/aws/codebuild/${var.project_id}-${var.environment}-delete-ecr-images" - stream_name = "" - } - } - source { - type = "GITHUB" - git_clone_depth = 0 - location = "https://github.com/nhsd-exeter/dos-integration.git" - buildspec = data.template_file.delete_ecr_images_buildspec.rendered - } -} diff --git a/infrastructure/stacks/development-pipeline/delete_task_environment_from_tag.tf b/infrastructure/stacks/development-pipeline/delete_task_environment_from_tag.tf deleted file mode 100644 index 7b8c27999..000000000 --- a/infrastructure/stacks/development-pipeline/delete_task_environment_from_tag.tf +++ /dev/null @@ -1,96 +0,0 @@ -resource "aws_codebuild_webhook" "destroy_environment_from_tag_deployment_webhook" { - count = var.environment == "dev" ? 1 : 0 - project_name = aws_codebuild_project.di_destroy_environment_from_tag[0].name - build_type = "BUILD" - filter_group { - filter { - type = "EVENT" - pattern = "PULL_REQUEST_CREATED" - } - - filter { - type = "HEAD_REF" - pattern = "^refs/tags/.*-destroy" - } - } - filter_group { - filter { - type = "EVENT" - pattern = "PULL_REQUEST_UPDATED" - } - - filter { - type = "HEAD_REF" - pattern = "^refs/tags/.*-destroy" - } - } -} - -resource "aws_codebuild_project" "di_destroy_environment_from_tag" { - count = var.environment == "dev" ? 1 : 0 - name = "${var.project_id}-${var.environment}-destroy-task-environments-stage" - description = "Destroys task environment based on tag" - build_timeout = "30" - queued_timeout = "5" - service_role = data.aws_iam_role.pipeline_role.arn - - artifacts { - type = "NO_ARTIFACTS" - } - - cache { - type = "LOCAL" - modes = ["LOCAL_DOCKER_LAYER_CACHE"] - } - - - environment { - compute_type = "BUILD_GENERAL1_SMALL" - image = "aws/codebuild/amazonlinux2-x86_64-standard:3.0" - type = "LINUX_CONTAINER" - image_pull_credentials_type = "CODEBUILD" - privileged_mode = true - - environment_variable { - name = "PROFILE" - value = "task" - } - environment_variable { - name = "CB_PROJECT_NAME" - value = "${var.project_id}-${var.environment}-destroy-task-environments-stage" - } - environment_variable { - name = "AWS_ACCOUNT_ID_LIVE_PARENT" - value = var.aws_account_id_live_parent - } - environment_variable { - name = "AWS_ACCOUNT_ID_MGMT" - value = var.aws_account_id_mgmt - } - environment_variable { - name = "AWS_ACCOUNT_ID_NONPROD" - value = var.aws_account_id_nonprod - } - environment_variable { - name = "AWS_ACCOUNT_ID_PROD" - value = var.aws_account_id_prod - } - environment_variable { - name = "AWS_ACCOUNT_ID_IDENTITIES" - value = var.aws_account_id_identities - } - } - logs_config { - cloudwatch_logs { - group_name = "/aws/codebuild/${var.project_id}-${var.environment}-destroy-task-environments-stage" - stream_name = "" - } - } - source { - type = "GITHUB" - git_clone_depth = 0 - location = "https://github.com/nhsd-exeter/dos-integration.git" - buildspec = data.template_file.delete_task_environment_from_tag_buildspec.rendered - } - -} diff --git a/infrastructure/stacks/development-pipeline/delete_task_environment_on_pr_merged.tf b/infrastructure/stacks/development-pipeline/delete_task_environment_on_pr_merged.tf deleted file mode 100644 index 292dcc09a..000000000 --- a/infrastructure/stacks/development-pipeline/delete_task_environment_on_pr_merged.tf +++ /dev/null @@ -1,84 +0,0 @@ -resource "aws_codebuild_webhook" "destroy_environment_on_pr_merged_deployment_webhook" { - count = var.environment == "dev" ? 1 : 0 - project_name = aws_codebuild_project.di_destroy_environment_on_pr_merged[0].name - build_type = "BUILD" - filter_group { - filter { - type = "EVENT" - pattern = "PULL_REQUEST_MERGED" - } - } -} - -resource "aws_codebuild_project" "di_destroy_environment_on_pr_merged" { - count = var.environment == "dev" ? 1 : 0 - name = "${var.project_id}-${var.environment}-destroy-task-environment-on-pr-merged-stage" - description = "Destroys task environment based on pr merged" - build_timeout = "30" - queued_timeout = "5" - service_role = data.aws_iam_role.pipeline_role.arn - - artifacts { - type = "NO_ARTIFACTS" - } - - cache { - type = "LOCAL" - modes = ["LOCAL_DOCKER_LAYER_CACHE"] - } - - - environment { - compute_type = "BUILD_GENERAL1_SMALL" - image = "aws/codebuild/amazonlinux2-x86_64-standard:3.0" - type = "LINUX_CONTAINER" - image_pull_credentials_type = "CODEBUILD" - privileged_mode = true - - environment_variable { - name = "PROFILE" - value = "task" - } - environment_variable { - name = "CB_PROJECT_NAME" - value = "${var.project_id}-${var.environment}-destroy-task-environment-on-pr-merged-stage" - } - environment_variable { - name = "AWS_ACCOUNT_ID_LIVE_PARENT" - value = var.aws_account_id_live_parent - } - environment_variable { - name = "AWS_ACCOUNT_ID_MGMT" - value = var.aws_account_id_mgmt - } - environment_variable { - name = "AWS_ACCOUNT_ID_NONPROD" - value = var.aws_account_id_nonprod - } - environment_variable { - name = "AWS_ACCOUNT_ID_PROD" - value = var.aws_account_id_prod - } - environment_variable { - name = "AWS_ACCOUNT_ID_IDENTITIES" - value = var.aws_account_id_identities - } - environment_variable { - name = "SERVERLESS_BUILD_PROJECT_NAME" - value = "${var.project_id}-${var.environment}-build-serverless-stage" - } - } - logs_config { - cloudwatch_logs { - group_name = "/aws/codebuild/${var.project_id}-${var.environment}-destroy-task-environment-on-pr-merged-stage" - stream_name = "" - } - } - source { - type = "GITHUB" - git_clone_depth = 0 - location = "https://github.com/nhsd-exeter/dos-integration.git" - buildspec = data.template_file.delete_task_environment_on_pr_merged_buildspec.rendered - } - -} diff --git a/infrastructure/stacks/development-pipeline/notify.tf b/infrastructure/stacks/development-pipeline/notify.tf deleted file mode 100644 index 2db88be3b..000000000 --- a/infrastructure/stacks/development-pipeline/notify.tf +++ /dev/null @@ -1,17 +0,0 @@ -resource "aws_sns_topic" "pipeline_notification_topic" { - name = var.pipeline_topic_name -} - - -resource "aws_codestarnotifications_notification_rule" "commits" { - detail_type = "BASIC" - event_type_ids = ["codepipeline-pipeline-pipeline-execution-started", "codepipeline-pipeline-pipeline-execution-failed", "codepipeline-pipeline-pipeline-execution-succeeded"] - - name = var.pipeline_notification_name - resource = aws_codepipeline.codepipeline.arn - - target { - type = "AWSChatbotSlack" - address = "arn:aws:chatbot::${var.aws_account_id_mgmt}:chat-configuration/slack-channel/${var.pipeline_chatbot_channel}" - } -} diff --git a/infrastructure/stacks/development-pipeline/providers.tf b/infrastructure/stacks/development-pipeline/providers.tf deleted file mode 100644 index 82d629877..000000000 --- a/infrastructure/stacks/development-pipeline/providers.tf +++ /dev/null @@ -1,12 +0,0 @@ -provider "aws" { - region = var.aws_region - default_tags { - tags = { - "Profile" = var.profile - "Programme" = var.programme - "Service" = var.project_id - "Product" = var.project_id - "Environment" = var.environment - } - } -} diff --git a/infrastructure/stacks/development-pipeline/terraform.tf b/infrastructure/stacks/development-pipeline/terraform.tf deleted file mode 100644 index d409d054c..000000000 --- a/infrastructure/stacks/development-pipeline/terraform.tf +++ /dev/null @@ -1,5 +0,0 @@ -terraform { - backend "s3" { - encrypt = true - } -} diff --git a/infrastructure/stacks/development-pipeline/unit-tests-buildspec.yml b/infrastructure/stacks/development-pipeline/unit-tests-buildspec.yml deleted file mode 100644 index 90b625515..000000000 --- a/infrastructure/stacks/development-pipeline/unit-tests-buildspec.yml +++ /dev/null @@ -1,15 +0,0 @@ -# For documentation see here - https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html -version: 0.2 - -phases: - build: - commands: - - cd application - - python -m pytest --junitxml=./testresults.xml --cov-report term-missing --cov-report xml:coverage.xml --cov=. -vv -reports: - unit-tests: - files: - - testresults.xml - base-directory: $CODEBUILD_SRC_DIR/application - discard-paths: yes - file-format: JUNITXML diff --git a/infrastructure/stacks/development-pipeline/variables.tf b/infrastructure/stacks/development-pipeline/variables.tf deleted file mode 100644 index 484236ab9..000000000 --- a/infrastructure/stacks/development-pipeline/variables.tf +++ /dev/null @@ -1,89 +0,0 @@ -########################## -# INFRASTRUCTURE COMPONENT -########################## - -############ -# AWS COMMON -############ - -variable "aws_profile" { - description = "The AWS profile" -} - -variable "aws_region" { - description = "The AWS region" -} - -variable "aws_account_id" { - description = "AWS account Number for Athena log location" -} -variable "lambda_security_group_name" { - description = "Name of the lambda security group" -} -variable "aws_account_id_nonprod" { - description = "" -} -variable "aws_account_id_mgmt" { - description = "" -} -variable "aws_account_id_prod" { - description = "" -} - -variable "aws_account_id_identities" { - description = "" -} -variable "aws_np_vpc_name" { - description = "" -} - -variable "aws_account_id_tools" { - description = "" - default = "00000000000" -} -variable "aws_account_id_live_parent" { - description = "" -} -# ############## -# # TEXAS COMMON -# ############## - - -variable "code_pipeline_branch_name" { - description = "" -} -variable "pipeline_notification_name" { - description = "" -} - -variable "pipeline_chatbot_channel" { - description = "" -} - -variable "pipeline_topic_name" { - description = "" -} - - -variable "github_owner" { - description = "" -} - -variable "github_repo" { - description = "" -} - -variable "profile" { - description = "The tag used to identify profile e.g. dev, test, live, ..." -} - -variable "programme" { - description = "Programme name" -} - -variable "project_id" { - description = "Project ID" -} -variable "environment" { - description = "Environment name" -} diff --git a/infrastructure/stacks/dos-api-gateway-mock/.terraform.lock.hcl b/infrastructure/stacks/dos-api-gateway-mock/.terraform.lock.hcl deleted file mode 100644 index 874ac1906..000000000 --- a/infrastructure/stacks/dos-api-gateway-mock/.terraform.lock.hcl +++ /dev/null @@ -1,40 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "4.9.0" - hashes = [ - "h1:GtmIOZMkKmr9tMLWouHWiGXmKEL/diOTNar5XfOVLjs=", - "zh:084b83aef3335ad4f5e4b8323c6fe43c1ff55e17a7647c6a5cad6af519f72b42", - "zh:132e47ce69f14de4523b84b213cedf7173398acda14245b1ffe7747aac50f050", - "zh:2068baef7dfce3613f3b4f27314175e971f8db68d9cde9ec30b5659f80c68c6c", - "zh:63c6f489683d5f1ac55e82a0df387143ed22701d5f22c109a4d5c9924dd4e437", - "zh:8115fd21965954fa4568c09331e05bb29da967fab8d077419aed09954378e216", - "zh:8efdc95fde108f777ed9c79ae25dc17aea9771903250f5c5c8a4c726b90a345f", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:9d42a7bc34d84b70c1d1bcc215cabd63abbcbd0352b70bd84da6c3916634932f", - "zh:aacbcceb241aa475888c0869e87593182edeced3170c76a0c960dd9c905df449", - "zh:c7fe7904511052e4102870256819a1917177572cf684f0611ebf767f9c1fbaa8", - "zh:c8e07c3424663d1d0e7e32f4ade8099c19f6326d37c6da98104d90c986ff66fc", - "zh:e47cafbd38b56ef14fd8d727b4ffea847c166b1c684f585ee5fb78983b537248", - ] -} - -provider "registry.terraform.io/hashicorp/random" { - version = "3.1.2" - hashes = [ - "h1:5A5VsY5wNmOZlupUcLnIoziMPn8htSZBXbP3lI7lBEM=", - "zh:0daceba867b330d3f8e2c5dc895c4291845a78f31955ce1b91ab2c4d1cd1c10b", - "zh:104050099efd30a630741f788f9576b19998e7a09347decbec3da0b21d64ba2d", - "zh:173f4ef3fdf0c7e2564a3db0fac560e9f5afdf6afd0b75d6646af6576b122b16", - "zh:41d50f975e535f968b3f37170fb07937c15b76d85ba947d0ce5e5ff9530eda65", - "zh:51a5038867e5e60757ed7f513dd6a973068241190d158a81d1b69296efb9cb8d", - "zh:6432a568e97a5a36cc8aebca5a7e9c879a55d3bc71d0da1ab849ad905f41c0be", - "zh:6bac6501394b87138a5e17c9f3a41e46ff7833ad0ba2a96197bb7787e95b641c", - "zh:6c0a7f5faacda644b022e7718e53f5868187435be6d000786d1ca05aa6683a25", - "zh:74c89de3fa6ef3027efe08f8473c2baeb41b4c6cee250ba7aeb5b64e8c79800d", - "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:b29eabbf0a5298f0e95a1df214c7cfe06ea9bcf362c63b3ad2f72d85da7d4685", - "zh:e891458c7a61e5b964e09616f1a4f87d0471feae1ec04cc51776e7dec1a3abce", - ] -} diff --git a/infrastructure/stacks/dos-api-gateway-mock/authoriser-lambda.tf b/infrastructure/stacks/dos-api-gateway-mock/authoriser-lambda.tf deleted file mode 100644 index b60cd3dce..000000000 --- a/infrastructure/stacks/dos-api-gateway-mock/authoriser-lambda.tf +++ /dev/null @@ -1,96 +0,0 @@ -resource "aws_lambda_function" "authoriser_lambda" { - function_name = var.authoriser_lambda_name - role = aws_iam_role.authoriser_lambda_role.arn - package_type = "Image" - timeout = "30" - image_uri = "${var.docker_registry}/authoriser:${var.image_version}" - memory_size = 1024 - tracing_config { - mode = "Active" - } - environment { - variables = { - "DOS_API_GATEWAY_CREDENTIALS_SECRET_NAME" = var.dos_api_gateway_secret - "DOS_API_GATEWAY_USERNAME_KEY" = var.dos_api_gateway_secret_username_key - "DOS_API_GATEWAY_PASSWORD_KEY" = var.dos_api_gateway_secret_password_key - "POWERTOOLS_SERVICE_NAME" = var.powertools_service_name - } - } - depends_on = [ - aws_iam_role.authoriser_lambda_role, - aws_iam_role_policy.authoriser_lambda_role_policy, - aws_cloudwatch_log_group.authoriser_lambda_log_group - ] -} - -resource "aws_lambda_function_event_invoke_config" "authoriser_lambda_invoke_config" { - function_name = aws_lambda_function.authoriser_lambda.function_name - maximum_retry_attempts = 0 -} - -resource "aws_iam_role" "authoriser_lambda_role" { - name = "${var.authoriser_lambda_name}-role" - path = "/" - assume_role_policy = < object: + """Get metric data for a lambda function. + + Args: + metric_namespace (str): Cloudwatch metric namespace + metric_name (str): Cloudwatch metric name + lambda_name (str): Lambda function name + start_time (datetime): Start time for metrics query + end_time (datetime): End time for metrics query + statistic (str): Statistic to return + unit (str): Unit for metric + + Returns: + object: Cloudwatch metric response object, may include metric data + """ + return cloudwatch_client.get_metric_data( + MetricDataQueries=[ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Namespace": metric_namespace, + "MetricName": metric_name, + "Dimensions": [ + {"Name": "FunctionName", "Value": lambda_name}, + ], + }, + "Period": 60, + "Stat": statistic, + "Unit": unit, + }, + "ReturnData": True, + }, + ], + StartTime=start_time, + EndTime=end_time, + ) + + +def get_database_metric_data( + metric_namespace: str, + metric_name: str, + database_name: str, + start_time: datetime, + end_time: datetime, + statistic: str, + unit: str, +) -> str: + """Get metric data for a database. + + Args: + metric_namespace (str): Cloudwatch metric namespace + metric_name (str): Cloudwatch metric name + database_name (str): Database name + start_time (datetime): Start time for metrics query + end_time (datetime): End time for metrics query + statistic (str): Statistic to return + unit (str): Unit for metric + + Returns: + str: Cloudwatch metric response object, may include metric data + """ + return cloudwatch_client.get_metric_data( + MetricDataQueries=[ + { + "Id": "m2", + "MetricStat": { + "Metric": { + "Namespace": metric_namespace, + "MetricName": metric_name, + "Dimensions": [ + {"Name": "DBInstanceIdentifier", "Value": database_name}, + ], + }, + "Period": 60, + "Stat": statistic, + "Unit": unit, + }, + "ReturnData": True, + }, + ], + StartTime=start_time, + EndTime=end_time, + ) + + +def get_custom_metric_data( + metric_namespace: str, + metric_name: str, + start_time: datetime, + end_time: datetime, + statistic: str, +) -> str: + """Get metric data for a custom metric. + + Args: + metric_namespace (str): Cloudwatch metric namespace + metric_name (str): Cloudwatch metric name + start_time (datetime): Start time for metrics query + end_time (datetime): End time for metrics query + statistic (str): Statistic to return + + Returns: + str: Cloudwatch metric response object, may include metric data + """ + return cloudwatch_client.get_metric_data( + MetricDataQueries=[ + { + "Id": "m3", + "MetricStat": { + "Metric": { + "Namespace": metric_namespace, + "MetricName": metric_name, + "Dimensions": [ + {"Name": "environment", "Value": getenv("ENVIRONMENT")}, + ], + }, + "Period": 60, + "Stat": statistic, + }, + "ReturnData": True, + }, + ], + StartTime=start_time, + EndTime=end_time, + ) diff --git a/scripts/performance_test_results/metrics.py b/scripts/performance_test_results/metrics.py new file mode 100644 index 000000000..df85b66a4 --- /dev/null +++ b/scripts/performance_test_results/metrics.py @@ -0,0 +1,82 @@ +from datetime import datetime + +from cloudwatch import get_custom_metric_data, get_database_metric_data, get_lambda_metric_data + +CUSTOM_METRIC_NAMESPACE = "uec-dos-int" + + +def lambda_metrics(lambda_name: str, start_time: datetime, end_time: datetime) -> None: + """Get metrics for a lambda function. + + Args: + lambda_name (str): Lambda function name + start_time (datetime): Start time for metrics + end_time (datetime): End time for metrics + """ + average_duration(lambda_name, start_time, end_time) + error_count(lambda_name, start_time, end_time) + + +def throughput_metrics(start_time: datetime, end_time: datetime) -> None: + """Get throughput metrics from custom metrics. + + Args: + start_time (datetime): Start time for metrics + end_time (datetime): End time for metrics + """ + response = get_custom_metric_data(CUSTOM_METRIC_NAMESPACE, "UpdateRequestSuccess", start_time, end_time, "Sum") + total_services_reviewed_or_updated = int(sum(response["MetricDataResults"][0]["Values"])) + total_per_second = total_services_reviewed_or_updated / (end_time - start_time).total_seconds() + print(f"Services Updated/Reviewed (UpdateRequestSuccess): {total_services_reviewed_or_updated}") + print(f"Average Services Updated/Reviewed per second: {round(total_per_second,3)}") + + +def database_metrics(database_name: str, database_description: str, start_time: datetime, end_time: datetime) -> None: + """Get metrics for a database. + + Args: + database_name (str): Database name + database_description (str): Database description + start_time (datetime): Start time for metrics + end_time (datetime): End time for metrics + """ + db_cpu_utilization_response = get_database_metric_data( + "AWS/RDS", "CPUUtilization", database_name, start_time, end_time, "Maximum", "Percent" + ) + cpu_utilization = max(db_cpu_utilization_response["MetricDataResults"][0]["Values"]) + db_connections_response = get_database_metric_data( + "AWS/RDS", "DatabaseConnections", database_name, start_time, end_time, "Maximum", "Count" + ) + connections = int(max(db_connections_response["MetricDataResults"][0]["Values"])) + print(f"{database_description} - CPU Utilisation: {round(cpu_utilization,3)}%") + print(f"{database_description} - Connections: {connections}") + + +def average_duration(lambda_name: str, start_time: datetime, end_time: datetime) -> None: + """Get average duration for a lambda function. + + Args: + lambda_name (str): Lambda function name + start_time (datetime): Start time for metrics + end_time (datetime): End time for metrics + """ + response = get_lambda_metric_data( + "AWS/Lambda", "Duration", lambda_name, start_time, end_time, "Average", "Milliseconds" + ) + short_lambda_name = "-".join(lambda_name.split("-")[4:]) + values = response["MetricDataResults"][0]["Values"] + print(f"Average duration for {short_lambda_name} is {round(sum(values) / len(values),3)} ms") + + +def error_count(lambda_name: str, start_time: datetime, end_time: datetime) -> None: + """Get error count for a lambda function. + + Args: + lambda_name (str): Lambda function name + start_time (datetime): Start time for metrics + end_time (datetime): End time for metrics + """ + response = get_lambda_metric_data("AWS/Lambda", "Errors", lambda_name, start_time, end_time, "Sum", "Count") + short_lambda_name = "-".join(lambda_name.split("-")[4:]) + values = response["MetricDataResults"][0]["Values"] + print(f"Error count for {short_lambda_name} is {int(sum(values))}") diff --git a/scripts/performance_test_results/performance_test_results.py b/scripts/performance_test_results/performance_test_results.py new file mode 100644 index 000000000..55970e76d --- /dev/null +++ b/scripts/performance_test_results/performance_test_results.py @@ -0,0 +1,20 @@ +from datetime import datetime +from os import getenv + +from metrics import database_metrics, lambda_metrics, throughput_metrics + + +def performance_test_results() -> None: + """Get performance test results.""" + start_time = datetime.fromisoformat(getenv("START_TIME")) + end_time = datetime.fromisoformat(getenv("END_TIME")) + lambda_metrics(getenv("INGEST_CHANGE_EVENT_LAMBDA"), start_time, end_time) + lambda_metrics(getenv("SERVICE_MATCHER_LAMBDA"), start_time, end_time) + lambda_metrics(getenv("SERVICE_SYNC_LAMBDA"), start_time, end_time) + throughput_metrics(start_time, end_time) + database_metrics(getenv("DB_WRITER_NAME"), "Writer", start_time, end_time) + database_metrics(getenv("DB_READER_NAME"), "Reader", start_time, end_time) + + +if __name__ == "__main__": + performance_test_results() diff --git a/build/docker/fifo-dlq-handler/assets/.gitkeep b/temp_file similarity index 100% rename from build/docker/fifo-dlq-handler/assets/.gitkeep rename to temp_file diff --git a/test/.gitignore b/test/.gitignore index 1a7f26c4e..60a859081 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -1,5 +1,5 @@ # ===================================================================================================================== -# SEE: https://github.com/github/gitignore/blob/master/Python.gitignore +# SEE: https://github.com/github/gitignore/blob/main/Python.gitignore # Byte-compiled / optimized / DLL files __pycache__/ @@ -140,3 +140,6 @@ dmypy.json # Cython debug symbols cython_debug/ + +# Other +resources/*.csv diff --git a/test/demo/collection/demo.postman_collection.json b/test/demo/collection/demo.postman_collection.json deleted file mode 100644 index 4a8e74468..000000000 --- a/test/demo/collection/demo.postman_collection.json +++ /dev/null @@ -1,169 +0,0 @@ -{ - "info": { - "_postman_id": "f07839b2-1d26-4c27-ab7e-df687a531da2", - "name": "DI_Demo", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "222", - "item": [ - { - "name": "DEMO_EVENT_1 - Website Changes", - "request": { - "method": "POST", - "header": [ - { "key": "x-api-key", "value": "", "type": "text" }, - { - "key": "x_correlation_id", - "value": "DEMO_EVENT_1 - Website Changes", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"SearchKey\": \"ANEI1245\",\r\n \"ODSCode\": \"FAT91\",\r\n \"OrganisationName\": null,\r\n \"OrganisationTypeId\": \"PHA\",\r\n \"OrganisationType\": \"Pharmacy\",\r\n \"OrganisationStatus\": \"Visible\",\r\n \"ServiceType\": \"PHA\",\r\n \"ServiceSubType\": \"COMPH\",\r\n \"SummaryText\": \"\",\r\n \"URL\": \"https://my-pharmacy.com/\",\r\n \"Address1\": \"501-503 MANSFIELD ROAD, NOTTINGHAM\",\r\n \"Address2\": null,\r\n \"Address3\": null,\r\n \"City\": null,\r\n \"County\": null,\r\n \"Latitude\": 53.38030624389648,\r\n \"Longitude\": -1.4826949834823608,\r\n \"Postcode\": \"NG5 2JJ\",\r\n \"Phone\": \"0115 9606272\",\r\n \"Email\": \"health.my-pharmacy@nhs.net\",\r\n \"Website\": \"https://new-website.com\",\r\n \"OrganisationSubType\": null,\r\n \"OrganisationAliases\": [],\r\n \"OpeningTimes\": []\r\n}", - "options": { "raw": { "language": "json" } } - }, - "url": { - "raw": "https://uec-dos-integration-di-222.k8s-nonprod.texasplatform.uk/api/v1/nhsuk-event-receiver/test", - "protocol": "https", - "host": [ - "uec-dos-integration-di-222", - "k8s-nonprod", - "texasplatform", - "uk" - ], - "path": ["api", "v1", "nhsuk-event-receiver", "test"] - } - }, - "response": [] - }, - { - "name": "DEMO_EVENT_2 - No Changes", - "request": { - "method": "POST", - "header": [ - { "key": "x-api-key", "value": "", "type": "text" }, - { - "key": "x_correlation_id", - "value": "DEMO_EVENT_2 - No Changes", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"SearchKey\": \"ANEI1245\",\r\n \"ODSCode\": \"FAT91\",\r\n \"OrganisationName\": null,\r\n \"OrganisationTypeId\": \"PHA\",\r\n \"OrganisationType\": \"Pharmacy\",\r\n \"OrganisationStatus\": \"Visible\",\r\n \"ServiceType\": \"PHA\",\r\n \"ServiceSubType\": \"COMPH\",\r\n \"SummaryText\": \"\",\r\n \"URL\": \"https://my-pharmacy.com/\",\r\n \"Address1\": \"501-503 MANSFIELD ROAD, NOTTINGHAM\",\r\n \"Address2\": null,\r\n \"Address3\": null,\r\n \"City\": null,\r\n \"County\": null,\r\n \"Latitude\": 53.38030624389648,\r\n \"Longitude\": -1.4826949834823608,\r\n \"Postcode\": \"NG5 2JJ\",\r\n \"Phone\": \"0115 9606272\",\r\n \"Email\": \"health.my-pharmacy@nhs.net\",\r\n \"Website\": null,\r\n \"OrganisationSubType\": null,\r\n \"OrganisationAliases\": [],\r\n \"OpeningTimes\": []\r\n}", - "options": { "raw": { "language": "json" } } - }, - "url": { - "raw": "https://uec-dos-integration-di-222.k8s-nonprod.texasplatform.uk/api/v1/nhsuk-event-receiver/test", - "protocol": "https", - "host": [ - "uec-dos-integration-di-222", - "k8s-nonprod", - "texasplatform", - "uk" - ], - "path": ["api", "v1", "nhsuk-event-receiver", "test"] - } - }, - "response": [] - }, - { - "name": "DEMO_EVENT_3 - ODS Mismatch", - "request": { - "method": "POST", - "header": [ - { "key": "x-api-key", "value": "", "type": "text" }, - { - "key": "x_correlation_id", - "value": "DEMO_EVENT_3 - ODS Mismatch", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"SearchKey\": \"ANEI1245\",\r\n \"ODSCode\": \"8JP76\",\r\n \"OrganisationName\": null,\r\n \"OrganisationTypeId\": \"PHA\",\r\n \"OrganisationType\": \"Pharmacy\",\r\n \"OrganisationStatus\": \"Visible\",\r\n \"ServiceType\": \"PHA\",\r\n \"ServiceSubType\": \"COMPH\",\r\n \"SummaryText\": \"\",\r\n \"URL\": \"https://my-pharmacy.com/\",\r\n \"Address1\": \"501-503 MANSFIELD ROAD, NOTTINGHAM\",\r\n \"Address2\": null,\r\n \"Address3\": null,\r\n \"City\": null,\r\n \"County\": null,\r\n \"Latitude\": 53.38030624389648,\r\n \"Longitude\": -1.4826949834823608,\r\n \"Postcode\": \"NG5 2JJ\",\r\n \"Phone\": \"0115 9606272\",\r\n \"Email\": \"health.my-pharmacy@nhs.net\",\r\n \"Website\": null,\r\n \"OrganisationSubType\": null,\r\n \"OrganisationAliases\": [],\r\n \"OpeningTimes\": []\r\n}", - "options": { "raw": { "language": "json" } } - }, - "url": { - "raw": "https://uec-dos-integration-di-222.k8s-nonprod.texasplatform.uk/api/v1/nhsuk-event-receiver/test", - "protocol": "https", - "host": [ - "uec-dos-integration-di-222", - "k8s-nonprod", - "texasplatform", - "uk" - ], - "path": ["api", "v1", "nhsuk-event-receiver", "test"] - } - }, - "response": [] - }, - { - "name": "DEMO_EVENT_4 - Change Dentist", - "request": { - "method": "POST", - "header": [ - { "key": "x-api-key", "value": "", "type": "text" }, - { - "key": "x_correlation_id", - "value": "DEMO_EVENT_4 - Change Dentist", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"SearchKey\": \"ANEI1245\",\r\n \"ODSCode\": \"8JP76\",\r\n \"OrganisationName\": null,\r\n \"OrganisationTypeId\": \"DEN\",\r\n \"OrganisationType\": \"Dentist\",\r\n \"OrganisationStatus\": \"Visible\",\r\n \"ServiceType\": \"DEN\",\r\n \"ServiceSubType\": \"COMPH\",\r\n \"SummaryText\": \"\",\r\n \"URL\": \"https://my-pharmacy.com/\",\r\n \"Address1\": \"501-503 MANSFIELD ROAD, NOTTINGHAM\",\r\n \"Address2\": null,\r\n \"Address3\": null,\r\n \"City\": null,\r\n \"County\": null,\r\n \"Latitude\": 53.38030624389648,\r\n \"Longitude\": -1.4826949834823608,\r\n \"Postcode\": \"NG5 2JJ\",\r\n \"Phone\": \"0115 9606272\",\r\n \"Email\": \"health.my-pharmacy@nhs.net\",\r\n \"Website\": null,\r\n \"OrganisationSubType\": null,\r\n \"OrganisationAliases\": [],\r\n \"OpeningTimes\": []\r\n}", - "options": { "raw": { "language": "json" } } - }, - "url": { - "raw": "https://uec-dos-integration-di-222.k8s-nonprod.texasplatform.uk/api/v1/nhsuk-event-receiver/test", - "protocol": "https", - "host": [ - "uec-dos-integration-di-222", - "k8s-nonprod", - "texasplatform", - "uk" - ], - "path": ["api", "v1", "nhsuk-event-receiver", "test"] - } - }, - "response": [] - }, - { - "name": "DEMO_EVENT_5 - Time Changes", - "request": { - "method": "POST", - "header": [ - { "key": "x-api-key", "value": "", "type": "text" }, - { - "key": "x_correlation_id", - "value": "DEMO_EVENT_5 - Time Changes", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"SearchKey\": \"ANEI1245\",\r\n \"ODSCode\": \"FAT91\",\r\n \"OrganisationName\": null,\r\n \"OrganisationTypeId\": \"PHA\",\r\n \"OrganisationType\": \"Pharmacy\",\r\n \"OrganisationStatus\": \"Visible\",\r\n \"ServiceType\": \"PHA\",\r\n \"ServiceSubType\": \"COMPH\",\r\n \"SummaryText\": \"\",\r\n \"URL\": \"https://my-pharmacy.com/\",\r\n \"Address1\": \"501-503 MANSFIELD ROAD, NOTTINGHAM\",\r\n \"Address2\": null,\r\n \"Address3\": null,\r\n \"City\": null,\r\n \"County\": null,\r\n \"Latitude\": 53.38030624389648,\r\n \"Longitude\": -1.4826949834823608,\r\n \"Postcode\": \"NG5 2JJ\",\r\n \"Phone\": \"0115 9606272\",\r\n \"Email\": \"health.my-pharmacy@nhs.net\",\r\n \"Website\": null,\r\n \"OrganisationSubType\": null,\r\n \"OrganisationAliases\": [],\r\n \"OpeningTimes\": [{\r\n \"Weekday\": \"Monday\",\r\n \"Times\": \"09:15-12:45\",\r\n \"OffsetOpeningTime\": 540,\r\n \"OffsetClosingTime\": 780,\r\n \"OpeningTimeType\": \"General\",\r\n \"AdditionalOpeningDate\": \"\",\r\n \"IsOpen\": true\r\n },\r\n {\r\n \"Weekday\": \"Monday\",\r\n \"Times\": \"14:00-17:30\",\r\n \"OffsetOpeningTime\": 840,\r\n \"OffsetClosingTime\": 1050,\r\n \"OpeningTimeType\": \"General\",\r\n \"AdditionalOpeningDate\": \"\",\r\n \"IsOpen\": true\r\n }]\r\n}", - "options": { "raw": { "language": "json" } } - }, - "url": { - "raw": "https://uec-dos-integration-di-222.k8s-nonprod.texasplatform.uk/api/v1/nhsuk-event-receiver/test", - "protocol": "https", - "host": [ - "uec-dos-integration-di-222", - "k8s-nonprod", - "texasplatform", - "uk" - ], - "path": ["api", "v1", "nhsuk-event-receiver", "test"] - } - }, - "response": [] - } - ] - } - ] -} diff --git a/test/demo/demo_events/demo_event1_website_changes.json b/test/demo/demo_events/demo_event1_website_changes.json deleted file mode 100644 index 1a9a24d99..000000000 --- a/test/demo/demo_events/demo_event1_website_changes.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "ODSCode": "FAT91", - "OrganisationName": null, - "OrganisationTypeId": "PHA", - "OrganisationType": "Pharmacy", - "OrganisationSubType": "Community", - "OrganisationStatus": "Visible", - "Address1": "501-503 MANSFIELD ROAD, NOTTINGHAM", - "Address2": null, - "Address3": null, - "City": null, - "County": null, - "Postcode": "NG5 2JJ", - "Contacts": [{ - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": "https://new-website.com" - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": "0115 9606272" - } - ] -} diff --git a/test/demo/demo_events/demo_event2_no_changes.json b/test/demo/demo_events/demo_event2_no_changes.json deleted file mode 100644 index ab0934e58..000000000 --- a/test/demo/demo_events/demo_event2_no_changes.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "ODSCode": "FAT91", - "OrganisationName": null, - "OrganisationTypeId": "PHA", - "OrganisationType": "Pharmacy", - "OrganisationSubType": "Community", - "OrganisationStatus": "Visible", - "Address1": "501-503 MANSFIELD ROAD, NOTTINGHAM", - "Address2": null, - "Address3": null, - "City": null, - "County": null, - "Postcode": "NG5 2JJ", - "Contacts": [{ - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": "https://my-pharmacy.com/" - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": "0115 9606272" - } - ] -} diff --git a/test/demo/demo_events/demo_event3_invalid_odscode.json b/test/demo/demo_events/demo_event3_invalid_odscode.json deleted file mode 100644 index 62b0d5e98..000000000 --- a/test/demo/demo_events/demo_event3_invalid_odscode.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "ODSCode": "FAKE1", - "OrganisationName": null, - "OrganisationTypeId": "PHA", - "OrganisationType": "Pharmacy", - "OrganisationSubType": "Community", - "OrganisationStatus": "Visible", - "Address1": "501-503 MANSFIELD ROAD, NOTTINGHAM", - "Address2": null, - "Address3": null, - "City": null, - "County": null, - "Postcode": "NG5 2JJ", - "Contacts": [{ - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": "https://my-pharmacy.com/" - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": "0115 9606272" - } - ] -} diff --git a/test/demo/demo_events/demo_event4_dentist.json b/test/demo/demo_events/demo_event4_dentist.json deleted file mode 100644 index 6b3b68aad..000000000 --- a/test/demo/demo_events/demo_event4_dentist.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "ODSCode": "8JP76", - "OrganisationName": null, - "OrganisationTypeId": "DEN", - "OrganisationType": "Dentist", - "OrganisationSubType": "Community", - "OrganisationStatus": "Visible", - "Address1": "501-503 MANSFIELD ROAD, NOTTINGHAM", - "Address2": null, - "Address3": null, - "City": null, - "County": null, - "Postcode": "NG5 2JJ", - "Contacts": [{ - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": "http://www.FakePharmacy.co.uk/" - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": "https://my-pharmacy.com/" - } - ] -} diff --git a/test/demo/demo_events/demo_event5_openingtimes_changes.json b/test/demo/demo_events/demo_event5_openingtimes_changes.json deleted file mode 100644 index a65b1f7bc..000000000 --- a/test/demo/demo_events/demo_event5_openingtimes_changes.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "ODSCode": "FAT91", - "OrganisationName": null, - "OrganisationTypeId": "PHA", - "OrganisationType": "Pharmacy", - "OrganisationSubType": "Community", - "OrganisationStatus": "Visible", - "Address1": "501-503 MANSFIELD ROAD, NOTTINGHAM", - "Address2": null, - "Address3": null, - "City": null, - "County": null, - "Postcode": "NG5 2JJ", - "Email": "health.my-pharmacy@nhs.net", - "Contacts": [{ - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": "https://my-pharmacy.com/" - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": "0115 9606272" - } - ], - "OpeningTimes": [{ - "Weekday": "Monday", - "OpeningTime": "09:15", - "ClosingTime": "12:45", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Monday", - "OpeningTime": "14:00", - "ClosingTime": "17:30", - "OffsetOpeningTime": 840, - "OffsetClosingTime": 1050, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }] -} diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 000000000..7699b3884 --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,227 @@ +# DOS Integration Test Suite + +The integration test suite is contained within test/integration and comprises a pytest suite covering various areas of the codebase. Setup is performed using the makefile in the root directory. This readme will cover at a high level the contents of the test suite and basic maintenance of it. + +- [DOS Integration Test Suite](#dos-integration-test-suite) + - [Initial Setup](#initial-setup) + - [Running the test suite](#running-the-test-suite) + - [Tags](#tags) + - [Environment](#environment) + - [Running tests](#running-tests) + - [Adding a new test](#adding-a-new-test) + - [Creating the Feature file entry](#creating-the-feature-file-entry) + - [Creating a scenario](#creating-a-scenario) + - [Creating a Given/When/Then function](#creating-a-givenwhenthen-function) + - [Functions with no variable](#functions-with-no-variable) + - [Functions with one or more variables](#functions-with-one-or-more-variables) + - [Files of note when creating tests](#files-of-note-when-creating-tests) + - [Utilities](#utilities) + - [Generator](#generator) + - [Dos DB Handler](#dos-db-handler) + - [Generic Steps](#generic-steps) + - [Given a basic service is created](#given-a-basic-service-is-created) + - [Given an entry is created in the services table](#given-an-entry-is-created-in-the-services-table) + - [Given the change event field is set to value](#given-the-change-event-field-is-set-to-value) + - [When the Changed Event is sent for processing with "valid" api key](#when-the-changed-event-is-sent-for-processing-with-valid-api-key) + - [Then the "lambda" lambda shows field "field" with value "message"](#then-the-lambda-lambda-shows-field-field-with-value-message) + - [Then DoS has "value" in the "field" field](#then-dos-has-value-in-the-field-field) +- [Data Generation](#data-generation) + - [Data Generation variable contents](#data-generation-variable-contents) + - [Opening Times variable entries](#opening-times-variable-entries) + - [Supporting Steps](#supporting-steps) + - [the service "{field\_name}" is set to "{values}"](#the-service-field_name-is-set-to-values) + - [a basic service is created](#a-basic-service-is-created) + - [the service is "{service\_status}" on "{day}"](#the-service-is-service_status-on-day) + - [the service is "{service\_status}" on date "{date}"](#the-service-is-service_status-on-date-date) + - [the entry is committed to the services table](#the-entry-is-committed-to-the-services-table) + - [Context Change Event Variable](#context-change-event-variable) + - [Notes](#notes) + - [Contact Information](#contact-information) + - [Opening Times](#opening-times) + + +## Initial Setup + +The initial setup of the test suite is the same as the DoS Integration project as a whole. Please follow the setup steps contained within README.md in order to have the correct python and docker environments setup locally, as well as the correct AWS credentials. Once setup, the test suite can be run. +The test suite also requires a connection to the DoS nonprod VPN in order to validate changes made to the DOS DB. +The test suite also requires `assume` to be setup for the AWS account being used, otherwise multi-threading of tests will not function. + +## Running the test suite + +### Tags + +The test suite is setup to be run with a series of tags, that enable the runner to choose the tests being selected. Tags do not have to match exactly, they are matched as if they are followed by a wildcard, so for example, it is possible to run all pharmacy related tests with the TAG=complete variable set. The suite does not, however, support all Cucumber tag functionality, as the tags are passed through as a variable from the run command without the '@' symbol. +E.g. `TAG=complete` + +### Environment + +The test suite can also be run against any environment currently setup in the nonprod AWS account. This can be selected by adding the environment name in lowercase to the ENVIRONMENT argument when running the test suite. +E.g. +If your current environment is DS-123 then you can add `ENVIRONMENT=ds-123` to the run args and the test suite will run against this environment. The test environment uses "test" and the dev environment "dev". + +Note: Your personal IP address may need to be white listed on the selected environment to ensure that the test can function. + +### Running tests + +The test suite can therefore be run by using the following command: +`make integration-test PROFILE=dev ENVIRONMENT={ENV_NAME} TAG={TAGS}` + +This will run the test suite with all selected tagged tests being run against the selected environment. + +The configuration for this command can be found in the Makefile file in the root directory of the project. This will allow the retry timeout and count to be configured. This is also where ENV VARS can be setup if they need to be pulled in as part of the build process. + +## Adding a new test + +### Creating the Feature file entry + +In order to create a new test, you will need to create the test in a feature file. There are currently 6 feature files in the test suite, though more can be added if it benefits organisation. +This feature file uses the Cucumber/Gherkin test syntax, where tests are written with a series of Given, When or Then statements. These feature files can be found at test/integration/features. Creating a new test involves adding a new scenario into one of these files, using either pre-existing test steps, or creating new ones, and then setting up the tags that the test will use. + +### Creating a scenario + +The scenario creation uses Pytest BDD. Documentation for this can be found [here](https://pytest-bdd.readthedocs.io/en/stable/#example) + +### Creating a Given/When/Then function + +If you are creating new functionality then you will need to create a new given/when/then statement in the test_steps.py file that can be found at `test/integration/steps/test_steps.py`. +This file contains a series of functions that are called when the Scenario steps are parsed. The functions in this file typically follow two formats: + +### Functions with no variable + +Many functions are just hard coded to perform a single task. These are setup with no input variables other than the test context and they look like this: + +``` +@then("the Changed Event is stored in dynamo db") +def stored_dynamo_db_events_are_pulled(context: Context): + [function code] + return context +``` + +There is an @tag at the beginning that will determine which type of test step calls the function. The available tags are @given @when and @then. A unique function name also needs to be determined and the context should generally be returned at the end of the function to ensure that the test context is up to date at step completion. + +### Functions with one or more variables + +It is also possible to have feature steps pass one or more variables through to the step function, too. This involves parsing the step text that's passed through by wrapping the readable text in a parse function. These steps look like this: + +``` +@then(parse('the test variable is "{test_var}"')) +def test_var_function(context: Context, test_var: str): + [function code] + return context +``` + +We can see that a parse has been added, as well as a value within speech marks and curly brackets. The speech marks are not required, but formatting of the test suite means we use it to denote a variable being held within. Setting a test function up like this allows you to pass a variable in. These variables are always input as a string. Multiple variables can be added to a single step. + +## Files of note when creating tests + +These files may be amended as part of creating tests in the test suite. + +### Utilities + +There are a variety of useful functions for the test suite that may be used by multiple steps, or multiple times. These are stored in the utils.py file, located at test/integration/steps/utilities/utils.py. +When creating a test, any functions should be stored here. + +### Generator + +There is a generator file that contains functions used for the creation of data in the DOS DB for the test suite. This also contains some functions related to the creation of the change event within the test context. + +### Dos DB Handler + +In some instances there may need to be some changes to the DoS DB Handler. This runs SQL queries for the test suite remotely, from a lambda function. This file can be found at application/dos_db_handler/dos_db_handler.py + +## Generic Steps + +These are some generic steps that are commonly used across the test suite. + +### Given a basic service is created + +This step creates a service in the DoS DB and sets the local context.change_event up to match the service. + +### Given an entry is created in the services table + +This step initilises the generator_data variable without writing out to the DOS DB. This step can be followed with steps that populate the generator_data (i.e. Given the service is "open/closed" on date "date" or Given the service "field_name" is set to "values") and then closed with a Given the entry is committed to the services table + +### Given the change event field is set to value + +This sets a value in the change event. This generic step should be able to change any root level value, or contacts/address data. + +### When the Changed Event is sent for processing with "valid" api key + +This step sends the current stored change_event to the API Gateway of the selected environment. + +### Then the "lambda" lambda shows field "field" with value "message" + +This is a generic log check step that will ensure a value is found in a certain field of a lambdas logs. + +### Then DoS has "value" in the "field" field + +This is a generic DoS check to ensure a value is found in the DoS DB services table. + +# Data Generation + +## Data Generation variable contents + +When creating data for a test step, a variable called generator_data is created in the function a_service_table_entry_is_created in test_steps.py. This variable is a dict with the following format: +``` + { + "id": A 6 digit generated numeric string, + "uid": "test" + A 5 digit generated numeric string, + "name": "Test Pharmacy" + a 3 digit number, + "odscode": A 5 digit generated numeric string, + "address": A 3 digit number followed by "Test Address", + "town": "Nottingham", + "postcode": "NG11GS", + "publicphone": A randomly generated 11 digit number in string format, + "web": "www.google.com", + } +``` + +It's worth noting here that all variables are in string format, regardless of whether they're a numeric value or mixed characters. + +Once this variable is setup, values can be amended using a series of other test steps. New test steps can be setup to amend this value to, if the test requires it. + +### Opening Times variable entries + +There are 2 further keys in the data_generator dict that can be added later. These are the "specified_openings" and "standard_openings". These follow this format: +`standard_openings: [{day: "Monday", open: True, opening_time: "09:00", closing_time: "17:00"}]` +and: +`specified_openings: [{date: "25 Dec 2025", open: True, opening_time: "09:00", closing_time: "17:00"}]` + +These get built in their own specified test steps, as they require separate logic in order to create the data in DOS and the change event correctly. + +## Supporting Steps + +### the service "{field_name}" is set to "{values}" + +This step sets a variable in the data_generator variable to whatever was input. This can be used to setup custom entries for all the fields listed above. If a specific web or phone are needed, for example, or alternatively an invalid postcode, this step can be used to change the value. + +### a basic service is created + +This step creates a randomised entry in the services table with default values that can be used if the test case does not require any specific data setup. It will populate a single opening day so that opening times changes are valid, and it will generate a valid pharmacy change event containing all data necessary to run any test. + +### the service is "{service_status}" on "{day}" + +This allows you to set the service to be open or closed on a specific day of the week. This specifically relates to general opening times, not specified opening times. + +### the service is "{service_status}" on date "{date}" + +This is the same as the above step, except it sets an Additional opening date rather than a general opening time. + +### the entry is committed to the services table + +This converts the data_generator data into a valid SQL query, and writes it to the DOS DB, saving the data to DB and also building the context.change_event to contain the same information. + +## Context Change Event Variable + +The context.change_event variable is structured exactly the same as a change event that gets sent into the API Gateway. It can be built from data_generator data, by passing the context through to the build_change_event() function. This can then be updated by using specified test_steps that change values within the change event. +The change event gets sent through 'as-is' when requests are sent to the API Gateway. + +## Notes + +### Contact Information + +The contact information fields (publicphone and web) both require an update to the context.change_event variable if the CE has already been built. This is why the build_change_event_contacts function exists separately from the build change event function. + +### Opening Times + +The opening times data has several unique steps because of the complexity of the values that need to be set. The combinations can include openings, closures, breaks in opening and invalid values being set. This also utilises a build function to setup the change event to match the information present in the data_generator diff --git a/test/integration/features/F001_Valid_Change_Events.feature b/test/integration/features/F001_Valid_Change_Events.feature index 7f7212491..f4db2d5d5 100644 --- a/test/integration/features/F001_Valid_Change_Events.feature +++ b/test/integration/features/F001_Valid_Change_Events.feature @@ -1,95 +1,563 @@ -Feature: F001. Ensure valid change events are converted and sent to DOS +Feature: F001. Ensure valid change events are converted and sent to DoS -@complete @pharmacy_smoke_test @pharmacy_no_log_searches - Scenario: F001S001. A valid change event is processed and accepted by DOS - Given a Changed Event is valid + @complete @general + Scenario Outline: F001SXX1. Changes are processed for acceptable service types + Given an entry is created in the services table + And the service "service_type" is set to "" + And the service "service_status" is set to "" + And the entry is committed to the services table + And the change event "Postcode" is set to "CT1 1AA" When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the Changed Event is stored in dynamo db + Then the "Postcode" is updated within the DoS DB + And the service history is updated with the "Postcode" + And the service history shows "postalcode" change type is "modify" -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F001S002. All received Changed Events are archived in Dynamo DB - Given a Changed Event is valid + Examples: + | service_type | status | + | 13 | 1 | + | 131 | 1 | + | 132 | 1 | + | 134 | 1 | + | 137 | 1 | + | 148 | 1 | + | 149 | 1 | + + @complete @general + Scenario Outline: F001SXX2. Checking invalid service types and statuses variations are not matched + Given an entry is created in the services table + And the service "service_type" is set to "" + And the service "service_status" is set to "" + And the entry is committed to the services table + When the Changed Event is sent for processing with "valid" api key + Then the "service-matcher" lambda shows field "report_key" with value "UNMATCHED_PHARMACY" + + Examples: + | service_type | status | + | 19 | 1 | + | 13 | 2 | + | 13 | 6 | + | 13 | 7 | + | 131 | 2 | + | 132 | 2 | + | 134 | 2 | + | 137 | 2 | + | 13 | 3 | + | 131 | 3 | + | 132 | 3 | + | 134 | 3 | + | 137 | 3 | + | 148 | 2 | + | 148 | 3 | + | 149 | 2 | + | 149 | 3 | + | 148 | 4 | + | 149 | 4 | + | 148 | 5 | + | 149 | 5 | + + @complete @general + Scenario: F001SXX3. A Changed event with aligned data does not save an update to DoS + Given a basic service is created + When the Changed Event is sent for processing with "valid" api key + Then the change event response has status code "200" + And the response has security headers + And the "service-sync" lambda shows field "message" with value "No changes to save" + And the service history is not updated + + @complete @general + Scenario Outline: F001SXX4. A valid CE without a contact field + Given a basic service is created + And the "" value has been unset When the Changed Event is sent for processing with "valid" api key + Then the "" is updated within the DoS DB + And the service history is updated with the "" + + Examples: + | field | + | website | + | phone | + + @complete @general + Scenario: F001SXX5. A duplicate sequence number is allowed + Given a basic service is created + And the ODS has an entry in dynamodb + When the Changed Event is sent for processing with a duplicate sequence id Then the Changed Event is stored in dynamo db + And the "ingest-change-event" lambda shows field "message" with value "Added record to dynamodb" -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F001S003. A Changed event with aligned data does not create a CR - Given a Changed Event is aligned with Dos + @complete @general + Scenario Outline: F001SXX6. Changed Event with URL variations is formatted and accepted by Dos + Given a basic service is created + And the change event "website" is set to "" When the Changed Event is sent for processing with "valid" api key - Then no Changed request is created - And the Changed Event is stored in dynamo db + Then DoS has "" in the "" field + + Examples: Web address variations + | url | expected_url | field | + | https://www.Test.com | https://www.test.com | website | + | https://www.TEST.Com | https://www.test.com | website | + | https://www.Test.com/TEST | https://www.test.com/TEST | website | + | http://www.TestChemist.co.uk | http://www.testchemist.co.uk | website | + | https://Testchemist.co.Uk | https://testchemist.co.uk | website | + | https://Www.testpharmacy.co.uk | https://www.testpharmacy.co.uk | website | + | https://www.rowlandspharmacy.co.uk/test?foo=test | https://www.rowlandspharmacy.co.uk/test?foo=test | website | -@complete @pharmacy_no_log_searches - Scenario: F001S004. A valid change event with changed Phone number is processed and captured by DOS - Given a Changed Event with changed "phone_no" is valid + @complete @general + Scenario Outline: F001SXX7. Changed Event with address line variations is title cased and accepted by Dos + Given a basic service is created + And the change event "Address1" is set to "
" When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the Changed Request with changed "phone_no" is captured by Dos + Then DoS has "" in the "" field -@complete @pharmacy_no_log_searches - Scenario: F001S005. A valid change event with changed website is processed and captured by DOS - Given a Changed Event with changed "website" is valid + Examples: Address variations + | address | expected_address | field | + | 5 TESTER WAY | 5 Tester Way$Nottingham | address | + | 1 Test STREET | 1 Test Street$Nottingham | address | + | new test street | New Test Street$Nottingham | address | + | Tester's new street | Testers New Street$Nottingham | address | + | new & test avenue | New and Test Avenue$Nottingham | address | + | 49a test avenue | 49A Test Avenue$Nottingham | address | + + @complete @general + Scenario: F001SXX8. Changed Event with updated postcode to verify location changes + Given a basic service is created + And the change event "Postcode" is set to "PR4 2BE" When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the Changed Request with changed "website" is captured by Dos + Then DoS has "KIRKHAM" in the "town" field + And DoS has "341832" in the "easting" field + And DoS has "432011" in the "northing" field + And DoS has "53.781108" in the "latitude" field + And DoS has "-2.886537" in the "longitude" field -@complete @pharmacy_no_log_searches - Scenario: F001S006. A valid change event with changed address is processed and captured by DOS - Given a Changed Event with changed "address" is valid + @complete @general + Scenario: F001SX9. Locations update check for postcode change + Given a basic service is created + And the change event "Postcode" is set to "PR4 2BE" When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the Changed Request with changed address is captured by Dos + Then the service table has been updated with locations data -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F001S007. A valid change event with special characters is processed by DOS - Given a Changed Event is valid - And the website field contains special characters + @complete @general + Scenario: F001SX10. Locations update check service history + Given a basic service is created + And the change event "Postcode" is set to "PR4 2BE" When the Changed Event is sent for processing with "valid" api key - Then the Changed Request with special characters is accepted by DOS - And the Changed Event is stored in dynamo db + Then the service history table has been updated with locations data -@complete @dentist_no_log_searches @dentist_smoke_test - Scenario: F001S008. A valid Dentist change event is processed into DOS - Given a Dentist Changed Event is valid + @complete @general + Scenario: F001SX11. To check the emails sending + Given a basic service is created + And the correlation-id is "email" + And the change event "Address1" is set to "Test Address" + And a pending entry exists in the changes table for this service When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the Dentist changes with service type id is captured by Dos + Then the s3 bucket contains an email file matching the service uid + And the changes table shows change is now rejected - @complete @pharmacy_smoke_test @pharmacy_no_log_searches - Scenario: F001S009. A valid change with website removal is processed by dos - Given a Changed Event to unset "website" + @complete @opening_times + Scenario: F001SX12. Past Specified Opening Times on Dos are removed and updated + Given an entry is created in the services table + And the service is "open" on date "Dec 25 2020" + And the entry is committed to the services table + And the specified opening date is set to "future" date When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by DoS with "website" deleted - And the Changed Event is stored in dynamo db + Then the DoS service has been updated with the specified date and time is captured by DoS -@complete @pharmacy_smoke_test @pharmacy_no_log_searches - Scenario: F001S010. A valid change with phone removal is processed by dos - Given a Changed Event to unset "phone" + @complete @opening_times + Scenario: F001SX13. All specified opening times are removed from DoS + Given an entry is created in the services table + And the service is "open" on date "Dec 25 2020" + And the entry is committed to the services table + And the change event is "open" on date "Jan 10 2020" When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos with "phone" deleted - And the Changed Event is stored in dynamo db + Then the DoS DB has no open date in "2020" -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F001S011. No CR created with a None value as phone data - Given a Changed Event with value "None" for "phone_no" + @complete @opening_times + Scenario: F001SX14. Empty Specified opening times results in no change and no error + Given a basic service is created When the Changed Event is sent for processing with "valid" api key - Then the Event "processor" does not show "message" with message "phone is not equal" + Then the "service-sync" lambda shows field "message" with value "No valid pending changes found" + + @complete @opening_times + Scenario: F001SX15. Empty CE Specified opening times removes all SP times in DoS + Given an entry is created in the services table + And the service is "open" on date "Dec 25 2022" + And the entry is committed to the services table + And the specified opening date is set to "no" date + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Deleting all specified opening times" + + @complete @opening_times + Scenario: F001SX16. CE Specified Opening Times with future dates replaces empty Dos SP times + Given a basic service is created + And the specified opening date is set to "future" date + When the Changed Event is sent for processing with "valid" api key + Then the DoS service has been updated with the specified date and time is captured by DoS + + @complete @general + Scenario: F001SX17. No Staff field in CE doesn't cause errors + Given a basic service is created + And the change event "Postcode" is set to "CT1 1AA" + And the change event has no staff field + When the Changed Event is sent for processing with "valid" api key + Then the "Postcode" is updated within the DoS DB + + @complete @general + Scenario: F001SX18. Palliative Care Service with changed data flagged (added) + Given a basic service is created + And the change event has a palliative care entry + When the Changed Event is sent for processing with "valid" api key + Then palliative care is "added" to the service + And the service history shows "cmssgsdid" change type is "add" + + @complete @general + Scenario: F001SX19. Palliative Care Service with changed data flagged (removed) + Given a basic service is created + And the service in DoS supports palliative care + When the Changed Event is sent for processing with "valid" api key + Then palliative care is "removed" to the service + And the service history shows "cmssgsdid" change type is "delete" + + @complete @general + Scenario: F001SX20. Palliative Care Service with unchanged data not flagged + Given a basic service is created + And the service in DoS supports palliative care + And the change event has a palliative care entry + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Palliative Care is equal" + And the service history is not updated + + @complete @validation + Scenario Outline: F001SX21. Palliative Care. Non primary pharmacy service no check message + Given an entry is created in the services table + And the service "service_type" is set to "" + And the entry is committed to the services table + And the change event has a palliative care entry + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Not suitable for palliative care comparison" + And the service history is not updated + + Examples: + | service_type | + | 131 | + | 132 | + | 134 | + | 137 | + + @complete @general + Scenario Outline: F001SX22. Blood Pressure Service with changed data flagged (added) + Given a pharmacy service is created with type "13" + And an entry is created in the services table with a derivative odscode + And the service "service_type" is set to "148" + And the service "service_status" is set to "" + And the entry is committed to the services table + And the change event has a blood pressure entry + And the change event "Postcode" is set to "W1A 1AA" + When the Changed Event is sent for processing with "valid" api key + Then DoS has "1" in the "status" field + And the service history shows "cmsorgstatus" change type is "modify" + And blood pressure Z Code is added to the service + And the service history shows "cmssgsdid" change type is "add" + And the "Postcode" is updated within the DoS DB + And the service history shows "postalcode" change type is "modify" + + Examples: + | service_status | + | 2 | + | 3 | + + @complete @general + Scenario Outline: F001SX23. Blood Pressure Service with changed data flagged (removed) + Given an entry is created in the services table + And the service "service_type" is set to "148" + And the service "service_status" is set to "1" + And the entry is committed to the services table + When the Changed Event is sent for processing with "valid" api key + Then DoS has "2" in the "status" field + And the service history shows "cmsorgstatus" change type is "modify" + + @complete @general + Scenario Outline: F001SX24. Blood Pressure Service with unchanged data (active) + Given an entry is created in the services table + And the service "service_type" is set to "148" + And the service "service_status" is set to "1" + And the entry is committed to the services table + And the change event has a blood pressure entry + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Blood Pressure is equal" + And the service history is not updated + + @complete @general + Scenario Outline: F001SX25. Blood Pressure Service not updated (inactive) + Given a pharmacy service is created with type "13" + And an entry is created in the services table with a derivative service + And the service "service_type" is set to "148" + And the service "service_status" is set to "" + And the entry is committed to the services table + And the change event "Postcode" is set to "W1A 1AA" + When the Changed Event is sent for processing with "valid" api key + Then the "postcode" has not been changed in DoS + And the service history is not updated + + Examples: + | service_status | + | 2 | + | 3 | + + @complete @validation + Scenario Outline: F001SX26. Blood Pressure not checked for non blood pressure service + Given an entry is created in the services table + And the service "service_type" is set to "" + And the entry is committed to the services table + And the change event has a blood pressure entry + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Not Suitable for blood pressure comparison" + And the service history is not updated + + Examples: + | service_type | + | 13 | + | 131 | + | 132 | + | 134 | + | 137 | + + @complete @general + Scenario Outline: F001SX27. Contraception Service with changed data flagged (added) + Given a pharmacy service is created with type "13" + And an entry is created in the services table with a derivative odscode + And the service "service_type" is set to "149" + And the service "service_status" is set to "" + And the entry is committed to the services table + And the change event has a contraception entry + And the change event "Postcode" is set to "W1A 1AA" + When the Changed Event is sent for processing with "valid" api key + Then DoS has "1" in the "status" field + And the service history shows "cmsorgstatus" change type is "modify" + And contraception Z Code is added to the service + And the service history shows "cmssgsdid" change type is "add" + And the "Postcode" is updated within the DoS DB + And the service history shows "postalcode" change type is "modify" + + Examples: + | service_status | + | 2 | + | 3 | - @complete @dev @pharmacy_cloudwatch_queries - Scenario: F001S012. No CR created with empty phone data - Given a Changed Event with value "''" for "phone_no" + @complete @general + Scenario Outline: F001SX28. Contraception Service with changed data flagged (removed) + Given an entry is created in the services table + And the service "service_type" is set to "149" + And the service "service_status" is set to "1" + And the entry is committed to the services table When the Changed Event is sent for processing with "valid" api key - Then the Event "processor" does not show "message" with message "phone is not equal" + Then DoS has "2" in the "status" field + And the service history shows "cmsorgstatus" change type is "modify" -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F001S013. No CR created with a None value as website data - Given a Changed Event with value "None" for "website" + @complete @general + Scenario Outline: F001SX29. Contraception Service with unchanged data (active) + Given an entry is created in the services table + And the service "service_type" is set to "149" + And the service "service_status" is set to "1" + And the entry is committed to the services table + And the change event has a contraception entry When the Changed Event is sent for processing with "valid" api key - Then the Event "processor" does not show "message" with message "website is not equal" + Then the "service-sync" lambda shows field "message" with value "Contraception is equal" + And the service history is not updated - @complete @dev @pharmacy_cloudwatch_queries - Scenario: F001S014. No CR created with empty website data - Given a Changed Event with value "''" for "website" + @complete @general + Scenario Outline: F001SX30. Contraception Service not updated (inactive) + Given a pharmacy service is created with type "13" + And an entry is created in the services table with a derivative service + And the service "service_type" is set to "149" + And the service "service_status" is set to "" + And the entry is committed to the services table + And the change event "Postcode" is set to "W1A 1AA" When the Changed Event is sent for processing with "valid" api key - Then the Event "processor" does not show "message" with message "website is not equal" + Then the "postcode" has not been changed in DoS + And the service history is not updated + + Examples: + | service_status | + | 2 | + | 3 | + + @complete @validation + Scenario Outline: F001SX31. Contraception not checked for non contraception service + Given an entry is created in the services table + And the service "service_type" is set to "" + And the entry is committed to the services table + And the change event has a contraception entry + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Not Suitable for contraception comparison" + And the service history is not updated + + Examples: + | service_type | + | 13 | + | 131 | + | 132 | + | 134 | + | 137 | + + @complete @validation + Scenario Outline: F001SX32. Test only active or going to active services appear on the hidden or closed report + Given a pharmacy service is created with type "13" + And an entry is created in the services table with a derivative odscode + And the service "service_type" is set to "" + And the service "service_status" is set to "" + And the entry is committed to the services table + And the change event "OrganisationStatus" is set to "Closed" + When the Changed Event is sent for processing with "valid" api key + Then Hidden or Closed logs does not show closed services or not going to active services + And the service history is not updated + + Examples: + | service_type | service_status | + | 148 | 2 | + | 148 | 3 | + | 149 | 2 | + | 149 | 3 | + + @complete @validation + Scenario Outline: F001SX33. Blood Pressure not checked for non blood pressure service + Given an entry is created in the services table + And the service "service_type" is set to "149" + And the entry is committed to the services table + And the change event has a contraception entry + And the change event has a blood pressure entry + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Not Suitable for blood pressure comparison" + And the service history is not updated + + @complete @validation + Scenario: F001SX34. Contraception not checked for non contraception service + Given an entry is created in the services table + And the service "service_type" is set to "148" + And the entry is committed to the services table + And the change event has a contraception entry + And the change event has a blood pressure entry + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Not Suitable for contraception comparison" + And the service history is not updated + + @complete @general + Scenario Outline: F001SX35. Palliative Care remains unchanged with longer than 5 character odscode + Given a basic service is created with "" character odscode + And the service in DoS supports palliative care + And the change event has a palliative care entry + When the Changed Event is sent for processing with "valid" api key + Then palliative care is "applied" to the service + And the "service-sync" lambda shows field "message" with value "No change / Not suitable for palliative care comparison" + And the service history is not updated + + Examples: + | odscode_character_length | + | 6 | + | 8 | + + + @complete @validation + Scenario Outline: F001SX36. Palliative Care remains unchanged with longer than 5 character odscode + Given a basic service is created with "" character odscode + And the service in DoS supports palliative care + And the change event has no palliative care entry + When the Changed Event is sent for processing with "valid" api key + Then palliative care is "applied" to the service + # We need to revisit to the above line to make it more appropriate + And the "service-sync" lambda shows field "message" with value "No change / Not suitable for palliative care comparison" + And the service history is not updated + + Examples: + | odscode_character_length | + | 6 | + | 8 | + + @complete @validation + Scenario Outline: F001SX37. Palliative Care remains unchanged with longer than 5 character odscode + Given a basic service is created with "" character odscode + And the change event has a palliative care entry + When the Changed Event is sent for processing with "valid" api key + Then palliative care is "not applied" to the service + And the "service-sync" lambda shows field "message" with value "No change / Not suitable for palliative care comparison" + And the service history is not updated + + Examples: + | odscode_character_length | + | 6 | + | 8 | + + @complete @validation + Scenario Outline: F001SX38. Palliative Care remains unchanged with longer than 5 character odscode + Given a basic service is created with "" character odscode + And the change event has no palliative care entry + When the Changed Event is sent for processing with "valid" api key + Then palliative care is "not applied" to the service + And the "service-sync" lambda shows field "message" with value "No change / Not suitable for palliative care comparison" + And the service history is not updated + + Examples: + | odscode_character_length | + | 6 | + | 8 | + + @complete @general + Scenario Outline: F001SX39. Changed Event with publicphone variations is formatted and accepted by DoS + Given a basic service is created + And the change event "phone" is set to "" + When the Changed Event is sent for processing with "valid" api key + Then DoS has "" in the "phone" field + + Examples: Public Phone variations + | phone | expected_phone | + | 0123 456789 | 0123456789 | + | " 0123456789" | 0123456789 | + | "0123456789 " | 0123456789 | + | "012 34567 89" | 0123456789 | + + + @complete @general + Scenario Outline: F001SX40. Changes are processed successfully for service_type = "134" with OrganisationSubType = "DistanceSelling" + Given an entry is created in the services table + And the service "service_type" is set to "134" + And the service "service_status" is set to "1" + And the entry is committed to the services table + And the change event "OrganisationSubType" is set to "DistanceSelling" + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Update Request Success" + Then the Changed Event is stored in dynamo db + And the service history is not updated + When the change event "" is set to "" + When the Changed Event is sent for processing with "valid" api key + Then the Changed Event is stored in dynamo db + Then the "" is updated within the DoS DB + And the service history shows "" change type is "modify" + + Examples: + | field | value | DOS_field |service_hist_field | + | website | www.testonetwo.com | website | cmsurl | + | phone | 22459436909 | phone | cmstelephoneno | + | Address1 | 5 Tester Way | address | postaladdress | + + @complete @general + Scenario: F001SX41. Changed Event with updated postcode to verify location changes with service_type = "134" and OrganisationSubType = "DistanceSelling" + Given an entry is created in the services table + And the service "service_type" is set to "134" + And the service "service_status" is set to "1" + And the entry is committed to the services table + And the change event "OrganisationSubType" is set to "DistanceSelling" + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Update Request Success" + Then the Changed Event is stored in dynamo db + And the service history is not updated + When the change event "Postcode" is set to "PR4 2BE" + When the Changed Event is sent for processing with "valid" api key + Then the Changed Event is stored in dynamo db + Then DoS has "PR4 2BE" in the "Postcode" field + Then DoS has "KIRKHAM" in the "town" field + And DoS has "341832" in the "easting" field + And DoS has "432011" in the "northing" field + And DoS has "53.781108" in the "latitude" field + And DoS has "-2.886537" in the "longitude" field + And the service history is updated with the "Postcode" + And the service history shows "postalcode" change type is "modify" + diff --git a/test/integration/features/F002_Invalid_Change_Events.feature b/test/integration/features/F002_Invalid_Change_Events.feature index 372920e8b..fb2334910 100644 --- a/test/integration/features/F002_Invalid_Change_Events.feature +++ b/test/integration/features/F002_Invalid_Change_Events.feature @@ -1,183 +1,72 @@ Feature: F002. Invalid change event Exception handling -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S001. Unmatched DOS services exception is logged - Given a Changed Event with invalid ODSCode is provided + @complete @validation + Scenario: F002SXX1. A Changed Event where OrganisationTypeID is NOT PHA is reported and ignored + Given a basic service is created + And the change event "OrganisationTypeId" is set to "DEN" When the Changed Event is sent for processing with "valid" api key - Then no matched services were found - And the unmatched service exception is reported to cloudwatch - Then the Changed Event is not processed any further - And the Changed Event is not sent to Dos - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S002. Changed Event with Hidden Organisation status is reported - Given a Changed Event is valid - When the OrganisationStatus is defined as "Hidden" - And the Changed Event is sent for processing with "valid" api key - Then the hidden or closed exception is reported to cloudwatch - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S003. Changed Event with Closed Organisation status is not processed - Given a Changed Event is valid - When the OrganisationStatus is defined as "Closed" - And the Changed Event is sent for processing with "valid" api key - Then the Changed Event is not processed any further - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S004. A Changed Event where OrganisationTypeID is NOT PHA is reported and ignored - Given a Changed Event contains an incorrect OrganisationTypeID - When the Changed Event is sent for processing with "valid" api key - Then the exception is reported to cloudwatch - And the Changed Event is not processed any further - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S005. A Changed Event where OrganisationSubType is NOT Community is reported and ignored - Given a Changed Event contains an incorrect OrganisationSubtype - When the Changed Event is sent for processing with "valid" api key - Then the exception is reported to cloudwatch - And the Changed Event is not processed any further - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S006. A Changed Event with no postcode LAT Long Values is reported - Given a Changed Event is valid - When the postcode has no LAT Long values - And the Changed Event is sent for processing with "valid" api key - Then the invalid postcode exception is reported to cloudwatch - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S007. Address changes are discarded when postcode is invalid - Given a Changed Event is valid - When the postcode is invalid - And the Changed Event is sent for processing with "valid" api key - Then the 'address' from the changes is not included in the change request - And the 'postcode' from the changes is not included in the change request - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S008. Invalid Opening Times reported where Weekday is not identified - Given a Changed Event with the Weekday NOT present in the Opening Times data - When the Changed Event is sent for processing with "valid" api key - Then the OpeningTimes exception is reported to cloudwatch - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S009. Invalid Opening Times reported where OpeningTimeType is not defined as General or Additional - Given a Changed Event where OpeningTimeType is NOT defined correctly + Then the "ingest-change-event" lambda shows field "message" with value "Validation Error - Unexpected Org Type ID: 'DEN'" + And the service history is not updated + + @complete @validation + Scenario: F002SXX2. A Changed Event where OrganisationSubType is NOT Community is reported and ignored + Given a basic service is created + And the change event "OrganisationSubType" is set to "com" + And the change event staff field is populated When the Changed Event is sent for processing with "valid" api key - Then the OpeningTimes exception is reported to cloudwatch - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S010. IsOpen is true AND Times is blank - Given a Changed Event is valid - When the OpeningTimes Opening and Closing Times data are not defined - And the Changed Event is sent for processing with "valid" api key - Then the OpeningTimes exception is reported to cloudwatch - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S011. IsOpen is false AND Times NOT blank - Given a Changed Event with the openingTimes IsOpen status set to false + Then logs show staff data has been redacted + And error messages do not show Staff data + And the "ingest-change-event" lambda shows field "message" with value "Validation Error - Unexpected Org Sub Type ID: 'com'" + + @complete @validation + Scenario: F002SXX3. Address changes are discarded when postcode is invalid + Given a basic service is created + And the change event "Postcode" is set to "FAKE" + And the change event "Website" is set to "https://www.test.com" + And the change event "Address1" is set to "FAKE2" When the Changed Event is sent for processing with "valid" api key - Then the OpeningTimes exception is reported to cloudwatch - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S012. OpeningTimeType is Additional AND AdditionalOpening Date is Blank - Given a Changed Event is valid - When the OpeningTimes OpeningTimeType is Additional and AdditionalOpeningDate is not defined - And the Changed Event is sent for processing with "valid" api key - Then the OpeningTimes exception is reported to cloudwatch - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S013. An OpeningTime is received for the Day or Date where IsOpen is True and IsOpen is false - Given a Changed Event is valid - When an AdditionalOpeningDate contains data with both true and false IsOpen status - And the Changed Event is sent for processing with "valid" api key - Then the OpeningTimes exception is reported to cloudwatch - And the Changed Event is stored in dynamo db + Then the "address" has not been changed in DoS -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S014. All data required in the Opening times exception report is identifiable in the logs - Given a Changed Event is valid - When the OpeningTimes Opening and Closing Times data are not defined - And the Changed Event is sent for processing with "valid" api key - Then the attributes for invalid opening times report is identified in the logs - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S015. Pharmacy with overlapping opening times - Given a Changed Event is valid - And the Changed Event has overlapping opening times + @complete @validation + Scenario: F002SXX4. Invalid Opening Times reported where Weekday is not identified + Given a basic service is created + And the change event has no weekday present in opening times When the Changed Event is sent for processing with "valid" api key - Then the Changed Event is stored in dynamo db - And an invalid opening times error is generated + Then the "service-sync" lambda shows field "message" with value "Opening times are not valid" -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S016. Pharmacy with non '13%' service type code prompts error. - Given a Changed Event is valid - And the Changed Event has ODS Code "TP68G" + @complete @general + Scenario: F002SXX5. Invalid Opening Times reported where OpeningTimeType is not defined as General or Additional + Given a basic service is created + And the change event has an invalid openingtimetype When the Changed Event is sent for processing with "valid" api key - Then the Changed Event is stored in dynamo db - And the unmatched service type exception is reported to cloudwatch + Then the "service-sync" lambda shows field "message" with value "Opening times are not valid" -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F002S017. Pharmacies with generic bank holidays are reported in logs. - Given a Changed Event is valid - And the Changed Event has ODS Code "FJQ49" + @complete @general + Scenario: F002SXX6. IsOpen is true AND Times is blank + Given a basic service is created + And the change event has undefined opening and closing times When the Changed Event is sent for processing with "valid" api key - Then the Changed Event is stored in dynamo db - And the generic bank holiday exception is reported to cloudwatch - -@complete @dentist_cloudwatch_queries - Scenario: F002S018. Dentist Hidden uses correct report key - Given a Dentist Changed Event is valid - When the OrganisationStatus is defined as "Hidden" - And the Changed Event is sent for processing with "valid" api key - Then the Event Processor logs with report key "HIDDEN_OR_CLOSED" - -@complete @dentist_cloudwatch_queries - Scenario: F002S019. Dentist Invalid Postcode uses correct report key - Given a Dentist Changed Event is valid - When the postcode is invalid - And the Changed Event is sent for processing with "valid" api key - Then the Event Processor logs with report key "INVALID_POSTCODE" + Then the "service-sync" lambda shows field "message" with value "Opening times are not valid" + And the service history is not updated -@complete @dentist_cloudwatch_queries - Scenario: F002S020. Dentist Invalid Opening Times uses correct report key - Given a Dentist Changed Event is valid - And a Changed Event where OpeningTimeType is NOT defined correctly + @complete @general + Scenario: F002SXX7. IsOpen is false AND Times NOT blank + Given a basic service is created + And the change event has opening times open status set to false When the Changed Event is sent for processing with "valid" api key - Then the Event Processor logs with report key "INVALID_OPEN_TIMES" + Then the "service-sync" lambda shows field "message" with value "Opening times are not valid" -@complete @dentist_cloudwatch_queries - Scenario Outline: F002S021. Dentist Unmatched Pharmacy and Service report keys - Given a Dentist Changed Event is valid - And the Changed Event has ODS Code "" + @complete @general + Scenario: F002SXX8. OpeningTimeType is Additional AND AdditionalOpening Date is Blank + Given a basic service is created + And the change event has an additional date with no specified date When the Changed Event is sent for processing with "valid" api key - Then the Event Processor logs with report key "" + Then the "service-sync" lambda shows field "message" with value "Opening times are not valid" - Examples: - | ods_code | report_key | - | FQG8101 | UNMATCHED_SERVICE_TYPE | - | V00393b | UNMATCHED_PHARMACY | - -@complete @dentist_cloudwatch_queries - Scenario Outline: F002S023. Dentists with Invalid ODS Lengths. - Given a Dentist Changed Event is valid - And the Changed Event has ODS Code "" + @complete @validation + Scenario: F002SXX9. A Changed Event where OrganisationSubType is NOT DistanceSelling is reported and ignored + Given a basic service is created with type "134" + And the change event "OrganisationSubType" is set to "Distance Selling" When the Changed Event is sent for processing with "valid" api key - Then the Event "processor" shows field "error" with message "ODSCode Wrong Length" - And the Event "processor" does not show "message" with message "Getting matching DoS Services for odscode" - - Examples: - | ods_code | - | V00393 | - | V00393abc | + Then the "ingest-change-event" lambda shows field "message" with value "Validation Error - Unexpected Org Sub Type ID: 'Distance Selling'" + And the service history is not updated diff --git a/test/integration/features/F003_DoS_Security.feature b/test/integration/features/F003_DoS_Security.feature index 5f4dc1d99..45e5f50f2 100644 --- a/test/integration/features/F003_DoS_Security.feature +++ b/test/integration/features/F003_DoS_Security.feature @@ -1,7 +1,9 @@ Feature: F003. Endpoint security and reporting -@complete @security @pharmacy_smoke_test @pharmacy_no_log_searches + @complete @slack_and_infrastructure Scenario: F003S001. No api key sent to change event endpoint - Given a Changed Event is valid + Given a basic service is created When the Changed Event is sent for processing with "invalid" api key - Then the change request has status code "403" + Then the change event response has status code "403" + And the response has security headers + And the Slack channel shows an alert saying "DI 4XX Endpoint Errors" from "SHARED_ENVIRONMENT" diff --git a/test/integration/features/F004_Error_Handling.feature b/test/integration/features/F004_Error_Handling.feature index c9761f15e..c7c628c7c 100644 --- a/test/integration/features/F004_Error_Handling.feature +++ b/test/integration/features/F004_Error_Handling.feature @@ -1,115 +1,39 @@ Feature: F004. Error Handling - @dev - Scenario: F004S001. DOS rejects CE and returns SC 400 with invalid Correlation ID and logs error in Splunk - Given a Changed Event is valid - And the correlation-id is "Bad Request" - When the Changed Event is sent for processing with "valid" api key - Then the event is sent to the DLQ - And the DLQ logs the error for Splunk - And the "cr_dlq" logs show status code "400" - And the Changed Event is stored in dynamo db - - @dev - Scenario: F004S002. A CR with invalid Correlation ID gets rejected by events bridge and is NOT sent to DOS - Given a Changed Event is valid - And the correlation-id is "Bad Request" - When the Changed Event is sent for processing with "valid" api key - Then the "cr_dlq" logs show error message "Message Abandoned" - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F004S003. A Changed Event where Specified opening date is set as closed is captured - Given a specific Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the date for the specified opening time returns an empty list - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F004S004. A Changed Event where Standard opening day is set as closed is captured - Given a specific Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the day for the standard opening time returns an empty list - And the Changed Event is stored in dynamo db - -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F004S005. An exception is raised when Sequence number is not present in headers - Given an ODS has an entry in dynamodb + @complete @slack_and_infrastructure + Scenario: F004SXX5. An exception is raised when Sequence number is not present in headers + Given a basic service is created When the Changed Event is sent for processing with no sequence id - Then the change request has status code "400" - -@complete @pharmacy_no_log_searches - Scenario: F004S006. An exception is raised when Sequence number is a duplicate of current - Given an ODS has an entry in dynamodb - When the Changed Event is sent for processing with a duplicate sequence id - Then the Changed Request is accepted by Dos - And the Changed Event is stored in dynamo db + Then the change event response has status code "400" + And the response has security headers -@complete @dev @pharmacy_cloudwatch_queries - Scenario: F004S007. An Alphanumeric Sequence number raises a 400 Bad Request exception - Given an ODS has an entry in dynamodb + @complete @slack_and_infrastructure + Scenario: F004SXX6. An Alphanumeric Sequence number raises a 400 Bad Request exception + Given a basic service is created When the Changed Event is sent for processing with sequence id "ABCD1" - Then the change request has status code "400" + Then the change event response has status code "400" + And the Slack channel shows an alert saying "DI 4XX Endpoint Errors" from "SHARED_ENVIRONMENT" - @complete @dev @pharmacy_cloudwatch_queries - Scenario Outline: F004S008. Sequence number less than previous raises exception - Given an ODS has an entry in dynamodb + @complete @slack_and_infrastructure + Scenario Outline: F004SXX7. An exception is raised when Sequence number is less than previous + Given a basic service is created + And the ODS has an entry in dynamodb When the Changed Event is sent for processing with sequence id "" - Then the event processor logs should record a sequence error + Then the "ingest-change-event" lambda shows field "message" with value "Sequence id is smaller than the existing one" Examples: These are both lower than the default sequence-id values | seqid | | 1 | | -1234 | - -@complete - Scenario Outline: F004S009. Dentist and Pharmacy org types not accepted - Given a "" Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the Event "processor" shows field "message" with message "Validation Error" - - Examples: Organisation types - | org_type | - | dentist | - | pharmacy | - - -@complete - Scenario Outline: F004S010. Dentist and Pharmacy org types accepted - Given a "" Changed Event is valid + @complete @validation + Scenario Outline: F004SX14 Exception raised and CR created for Changed Event with invalid URL + Given a basic service is created + And the change event "website" is set to "" When the Changed Event is sent for processing with "valid" api key - Then the processed Changed Request is sent to Dos - - Examples: Organisation types - | org_type | - | pharmacy | - | dentist | + Then the "website" has not been changed in DoS - -@complete @dev @dentist_cloudwatch_queries - Scenario Outline: F004S011. Only the Dentist org type accepted - Given a "dentist" Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the processed Changed Request is sent to Dos - - -@complete @dev @dentist_cloudwatch_queries - Scenario Outline: F004S012. Exception is raised when unaccepted Pharmacy org type CE is processed - Given a "pharmacy" Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the Event "processor" shows field "message" with message "Validation Error" - - -@complete @dev @pharmacy_cloudwatch_queries - Scenario Outline: F004S013. Only the Pharmacy org type accepted - Given a "pharmacy" Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the processed Changed Request is sent to Dos - - -@complete @dev @pharmacy_cloudwatch_queries - Scenario Outline: F004S014. Exception is raised when unaccepted Dentist org type CE is processed - Given a "dentist" Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the Event "processor" shows field "message" with message "Validation Error" + Examples: Invalid Web address variations + | url | + | https://TESTPHARMACY@GMAIL.COM | + | test@gmail.com | diff --git a/test/integration/features/F005_Support_Functions.feature b/test/integration/features/F005_Support_Functions.feature index 606b6bb27..20b717330 100644 --- a/test/integration/features/F005_Support_Functions.feature +++ b/test/integration/features/F005_Support_Functions.feature @@ -1,9 +1,32 @@ Feature: F005. Support Functions -@complete @pharmacy_no_log_searches - Scenario: F005S001. An unprocessed Changed Event is replayed in DI - Given a Changed Event is valid + @complete @general + Scenario: F005SXX1. An unprocessed Changed Event is replayed in DI + Given a basic service is created When the Changed Event is sent for processing with "valid" api key Then the Changed Event is stored in dynamo db And the stored Changed Event is reprocessed in DI - And the reprocessed Changed Event is sent to Dos + + @complete @general + Scenario: F005SXX1. An unprocessed Changed Event with service_type = "134" and OrganisationSubType = "DistanceSelling" is replayed in DI + Given a basic service is created with type "134" + And the change event "OrganisationSubType" is set to "DistanceSelling" + When the Changed Event is sent for processing with "valid" api key + Then the Changed Event is stored in dynamo db + And the stored Changed Event is reprocessed in DI + + @complete @slack_and_infrastructure + Scenario: F005SXX2 SQS Message for Change Event DLQ Alert + Given a basic service is created + When a "change event dlq" SQS message is added to the queue + Then the Slack channel shows an alert saying "Change Events DLQ" from "SHARED_ENVIRONMENT" + + @complete @slack_and_infrastructure + Scenario Outline: F005SXX2 SQS Message DLQ Alert + When a "" SQS message is added to the queue + Then the Slack channel shows an alert saying "Update Requests DLQ" from "BLUE_GREEN_ENVIRONMENT" + + Examples: + | message_type | + | update request dlq | + | update request failure | diff --git a/test/integration/features/F006_Opening_Times.feature b/test/integration/features/F006_Opening_Times.feature new file mode 100644 index 000000000..a5676570e --- /dev/null +++ b/test/integration/features/F006_Opening_Times.feature @@ -0,0 +1,95 @@ +Feature: F006. Opening times + + @complete @opening_times + Scenario: F006SXX1. Confirm actual opening times change for specified date and time is captured by DoS + Given a basic service is created + And the change event is "open" on date "Dec 25 2028" + When the Changed Event is sent for processing with "valid" api key + Then the DoS service has been updated with the specified date and time is captured by DoS + And the service history is updated with the "added" specified opening times + + @complete @opening_times + Scenario: F006SXX2. Confirm actual opening times change for standard date and time is captured by Dos + Given a basic service is created + And the change event is "open" on "tuesday" + When the Changed Event is sent for processing with "valid" api key + Then the DoS service has been updated with the standard days and times is captured by DoS + And the service history is updated with the "modified" standard opening times + + @complete @opening_times + Scenario: F006SXX3. Pharmacy with one break in opening times + Given a basic service is created + And the Changed Event has "1" break in opening times + When the Changed Event is sent for processing with "valid" api key + Then opening times with a break are updated in DoS + + @complete @opening_times + Scenario: F006SXX4. Pharmacy with two breaks in opening times + Given a basic service is created + And the Changed Event has "2" break in opening times + When the Changed Event is sent for processing with "valid" api key + Then opening times with two breaks are updated in DoS + + @complete @opening_times + Scenario: F006SXX6. Confirm recently added specified opening date can be removed from Dos + Given a basic service is created + And the change event is "open" on date "Jan 01 2026" + When the Changed Event is sent for processing with "valid" api key + Then DoS is open on "Jan 01 2026" + Given the change event has no specified opening dates + When the Changed Event is sent for processing with "valid" api key + Then there is no longer a specified opening on "Jan 01 2026" + And the service history is updated with the "removed" specified opening times + + @complete @opening_times + Scenario: F006SX7. A recently opened pharmacy on a standard day can be closed + Given a basic service is created + And the change event is "open" on "Tuesday" + When the Changed Event is sent for processing with "valid" api key + Then the pharmacy is confirmed "open" on "Tuesday" + Given the change event is "closed" on "Tuesday" + When the Changed Event is sent for processing with "valid" api key + Then the pharmacy is confirmed "closed" on "Tuesday" + And the service history is updated with the "removed" standard opening times + + @complete @opening_times + Scenario: F006SX8. Additional date changes open to closed + Given an entry is created in the services table + And the service is "open" on date "Jan 01 2026" + And the entry is committed to the services table + And the change event is "closed" on date "Jan 01 2026" + When the Changed Event is sent for processing with "valid" api key + Then DoS is closed on "Jan 01 2026" + + @complete @opening_times + Scenario: F006SX9. Additional date changes closed to open + Given an entry is created in the services table + And the service is "closed" on date "Jan 01 2026" + And the entry is committed to the services table + And the change event is "open" on date "Jan 01 2026" + When the Changed Event is sent for processing with "valid" api key + Then DoS is open on "Jan 01 2026" + + @complete @opening_times + Scenario: F006SX10. Additional date changes times changed + Given an entry is created in the services table + And the service is "open" on date "Jan 01 2026" + And the entry is committed to the services table + And the change event specified opening is "open" from "10:00" to "16:00" on date "Jan 01 2026" + When the Changed Event is sent for processing with "valid" api key + Then DoS is open from "10:00" until "16:00" on "Jan 01 2026" + And the "service-sync" lambda does not show "report_key" with value "BLANK_STANDARD_OPENINGS" + + @complete @opening_times + Scenario: F006SXX11. Confirm actual opening times change for specified date and time is captured by DoS with service_type = "134" and OrganisationSubType = "DistanceSelling" + Given a basic service is created with type "134" + And the change event "OrganisationSubType" is set to "DistanceSelling" + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "message" with value "Update Request Success" + Then the Changed Event is stored in dynamo db + And the service history is not updated + When the change event is "open" on date "Dec 25 2028" + When the Changed Event is sent for processing with "valid" api key + Then the Changed Event is stored in dynamo db + Then the DoS service has been updated with the specified date is captured by DoS + And the service history is updated with the "added" specified opening times diff --git a/test/integration/features/F006_Opening_times.feature b/test/integration/features/F006_Opening_times.feature deleted file mode 100644 index c2b7c33a0..000000000 --- a/test/integration/features/F006_Opening_times.feature +++ /dev/null @@ -1,79 +0,0 @@ -Feature: F006. Opening times - -@complete @pharmacy_no_log_searches - Scenario: F006S001. Confirm actual opening times change for specified date and time is captured by Dos - Given an opened specified opening time Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the Changed Request with changed specified date and time is captured by Dos - -@complete @pharmacy_no_log_searches - Scenario: F006S002. Confirm actual opening times change for standard date and time is captured by Dos - Given an opened standard opening time Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the Changed Request with changed standard day time is captured by Dos - -@complete @pharmacy_no_log_searches - Scenario: F006S003. Pharmacy with one break in opening times - Given a Changed Event is valid - And the Changed Event has one break in opening times - When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the opening times changes are confirmed valid - -@complete @pharmacy_no_log_searches - Scenario: F006S004. Pharmacy with two breaks in opening times - Given a Changed Event is valid - And the Changed Event has two breaks in opening times - When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the opening times changes are confirmed valid - -@complete @pharmacy_no_log_searches - Scenario: F006S005. Pharmacy with one off opening date set to closed - Given a Changed Event is valid - And the Changed Event contains a one off opening date thats "Closed" - When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the opening times changes are confirmed valid - -@complete @pharmacy_no_log_searches - Scenario: F006S006. A Pharmacy with one off opening date set to open - Given a Changed Event is valid - And the Changed Event contains a one off opening date thats "Open" - When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the opening times changes are confirmed valid - -@complete @pharmacy_no_log_searches - Scenario: F006S007. Close pharmacy on bank holiday - Given a Changed Event is valid - And the Changed Event closes the pharmacy on a bank holiday - When the Changed Event is sent for processing with "valid" api key - Then the Changed Request is accepted by Dos - And the opening times changes are confirmed valid - -@complete @pharmacy_no_log_searches - Scenario: F006S008. Confirm recently added specified opening date can be removed from Dos - Given an opened specified opening time Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the Changed Request with changed specified date and time is captured by Dos - And the Changed Event is replayed with the specified opening date deleted - And the deleted specified date is confirmed removed from Dos - -@complete @pharmacy_no_log_searches - Scenario: F006S009. A recently closed pharmacy on a standard day can be opened - Given a specific Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the pharmacy is confirmed "closed" for the standard day in Dos - And the Changed Event is replayed with the pharmacy now "open" - And the pharmacy is confirmed "open" for the standard day in Dos - -@complete @pharmacy_no_log_searches - Scenario: F006S010. A recently opened pharmacy on a standard day can be closed - Given an opened standard opening time Changed Event is valid - When the Changed Event is sent for processing with "valid" api key - Then the pharmacy is confirmed "open" for the standard day in Dos - And the Changed Event is replayed with the pharmacy now "closed" - And the pharmacy is confirmed "closed" for the standard day in Dos diff --git a/test/integration/features/F007_Reporting.feature b/test/integration/features/F007_Reporting.feature new file mode 100644 index 000000000..5c2140b55 --- /dev/null +++ b/test/integration/features/F007_Reporting.feature @@ -0,0 +1,176 @@ +Feature: F007. Report Logging + + @complete @reporting + Scenario: F007SX01. Check for Invalid Open Times log + Given an entry is created in the services table + And the service is "open" on "Monday" + And the service is "open" on "Monday" + And the entry is committed to the services table + When the Changed Event is sent for processing with "valid" api key + Then the "service-matcher" lambda shows field "report_key" with value "INVALID_OPEN_TIMES" + And "nhsuk_odscode" attribute is identified in the "INVALID_OPEN_TIMES" report in "service-matcher" logs + And "nhsuk_organisation_name" attribute is identified in the "INVALID_OPEN_TIMES" report in "service-matcher" logs + And "nhsuk_open_times_payload" attribute is identified in the "INVALID_OPEN_TIMES" report in "service-matcher" logs + And "dos_service_type_name" attribute is identified in the "INVALID_OPEN_TIMES" report in "service-matcher" logs + And "dos_services" attribute is identified in the "INVALID_OPEN_TIMES" report in "service-matcher" logs + + @complete @reporting + Scenario Outline: F007SX02. Pharmacy with one off opening date set to closed + Given a basic service is created + And the change event is "" on date "" + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda does not show "report_key" with value "INVALID_OPEN_TIMES" + + Examples: + | open_or_closed | date | + | closed | Dec 25 2025 | + | open | Dec 25 2025 | + + @complete @reporting + Scenario: F007SX03 Check for generic change event error log + Given a basic service is created + And the change event "website" is set to "test@test.com" + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "report_key" with value "GENERIC_CHANGE_EVENT_ERROR" + And "ods_code" attribute is identified in the "GENERIC_CHANGE_EVENT_ERROR" report in "service-sync" logs + And "error_reason" attribute is identified in the "GENERIC_CHANGE_EVENT_ERROR" report in "service-sync" logs + And "error_info" attribute is identified in the "GENERIC_CHANGE_EVENT_ERROR" report in "service-sync" logs + And "dos_region" attribute is identified in the "GENERIC_CHANGE_EVENT_ERROR" report in "service-sync" logs + + @complete @reporting + Scenario: F007SX04 Check for services with generic bank holiday openings log + Given a basic service is created + And the change event "ODSCode" is set to "FJQ49" + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "report_key" with value "GENERIC_BANK_HOLIDAY" + And "nhsuk_odscode" attribute is identified in the "GENERIC_BANK_HOLIDAY" report in "service-sync" logs + And "nhsuk_organisation_name" attribute is identified in the "GENERIC_BANK_HOLIDAY" report in "service-sync" logs + And "dos_service_uid" attribute is identified in the "GENERIC_BANK_HOLIDAY" report in "service-sync" logs + And "dos_service_name" attribute is identified in the "GENERIC_BANK_HOLIDAY" report in "service-sync" logs + And "dos_service_type_name" attribute is identified in the "GENERIC_BANK_HOLIDAY" report in "service-sync" logs + And "bank_holiday_opening_times" attribute is identified in the "GENERIC_BANK_HOLIDAY" report in "service-sync" logs + And "nhsuk_parent_org" attribute is identified in the "GENERIC_BANK_HOLIDAY" report in "service-sync" logs + And "dos_region" attribute is identified in the "GENERIC_BANK_HOLIDAY" report in "service-sync" logs + + @complete @reporting + Scenario: F007SX05 Check for Unmatched Pharmacy Report log + Given a basic service is created + And the change event "ODSCode" is set to "FXXX1" + When the Changed Event is sent for processing with "valid" api key + Then the "service-matcher" lambda shows field "report_key" with value "UNMATCHED_PHARMACY" + And "nhsuk_odscode" attribute is identified in the "UNMATCHED_PHARMACY" report in "service-matcher" logs + And "nhsuk_organisation_name" attribute is identified in the "UNMATCHED_PHARMACY" report in "service-matcher" logs + And "nhsuk_organisation_typeid" attribute is identified in the "UNMATCHED_PHARMACY" report in "service-matcher" logs + And "nhsuk_organisation_subtype" attribute is identified in the "UNMATCHED_PHARMACY" report in "service-matcher" logs + And "nhsuk_organisation_status" attribute is identified in the "UNMATCHED_PHARMACY" report in "service-matcher" logs + And "nhsuk_address1" attribute is identified in the "UNMATCHED_PHARMACY" report in "service-matcher" logs + And "nhsuk_city" attribute is identified in the "UNMATCHED_PHARMACY" report in "service-matcher" logs + And "nhsuk_postcode" attribute is identified in the "UNMATCHED_PHARMACY" report in "service-matcher" logs + And "nhsuk_parent_organisation_name" attribute is identified in the "UNMATCHED_PHARMACY" report in "service-matcher" logs + And the service history is not updated + + @complete @reporting + Scenario: F007SX06 Check for Blank Opening Times Report log + Given an entry is created in the services table + And the service is "open" on "Monday" + And the entry is committed to the services table + And the Changed Event has blank opening times + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "report_key" with value "BLANK_STANDARD_OPENINGS" + And "nhsuk_odscode" attribute is identified in the "BLANK_STANDARD_OPENINGS" report in "service-sync" logs + And "dos_service_name" attribute is identified in the "BLANK_STANDARD_OPENINGS" report in "service-sync" logs + And "dos_region" attribute is identified in the "BLANK_STANDARD_OPENINGS" report in "service-sync" logs + And "dos_service_uid" attribute is identified in the "BLANK_STANDARD_OPENINGS" report in "service-sync" logs + And "dos_service_type_name" attribute is identified in the "BLANK_STANDARD_OPENINGS" report in "service-sync" logs + And the service history is not updated + + @complete @reporting + Scenario Outline: F007SX07 Check for Hidden Or Closed Report log + Given a pharmacy service is created with type "" + And the change event "OrganisationStatus" is set to "" + When the Changed Event is sent for processing with "valid" api key + Then the "service-matcher" lambda shows field "message" with value "NHS Service marked as closed or hidden" + And the "service-matcher" lambda shows field "report_key" with value "HIDDEN_OR_CLOSED" + And "dos_service_uid" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And "nhsuk_odscode" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And "dos_service_name" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And "nhsuk_service_status" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And "nhsuk_service_type" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And "nhsuk_sector" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And "dos_service_status" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And "dos_service_type" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And "dos_region" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And "nhsuk_parent_organisation_name" attribute is identified in the "HIDDEN_OR_CLOSED" report in "service-matcher" logs + And the service history is not updated + + Examples: + | service_type | OrganisationStatus | + | 13 | Closed | + | 13 | Hidden | + + @complete @reporting + Scenario: F007SX08 Check for Invalid Postcode Report log + Given a basic service is created + And the change event "Postcode" is set to "FAKE" + When the Changed Event is sent for processing with "valid" api key + Then the "service-sync" lambda shows field "report_key" with value "INVALID_POSTCODE" + And "nhsuk_odscode" attribute is identified in the "INVALID_POSTCODE" report in "service-sync" logs + And "nhsuk_organisation_name" attribute is identified in the "INVALID_POSTCODE" report in "service-sync" logs + And "nhsuk_address1" attribute is identified in the "INVALID_POSTCODE" report in "service-sync" logs + And "nhsuk_city" attribute is identified in the "INVALID_POSTCODE" report in "service-sync" logs + And "nhsuk_postcode" attribute is identified in the "INVALID_POSTCODE" report in "service-sync" logs + And "validation_error_reason" attribute is identified in the "INVALID_POSTCODE" report in "service-sync" logs + And "dos_service" attribute is identified in the "INVALID_POSTCODE" report in "service-sync" logs + And "dos_region" attribute is identified in the "INVALID_POSTCODE" report in "service-sync" logs + And "dos_service_name" attribute is identified in the "INVALID_POSTCODE" report in "service-sync" logs + And the Slack channel shows an alert saying "Invalid Postcode" from "BLUE_GREEN_ENVIRONMENT" + And the service history is not updated + + @complete @reporting + Scenario Outline: F007SX09 Check for missing dos service type + Given a basic service is created + And the change event "" is set to "True" + When the Changed Event is sent for processing with "valid" api key + Then the "service-matcher" lambda shows field "report_key" with value "MISSING_SERVICE_TYPE" + And "ods_code" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "org_type" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "org_sub_type" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "nhsuk_organisation_status" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "dos_missing_service_type" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "dos_service_address" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "dos_service_postcode" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "nhsuk_parent_organisation_name" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "dos_region" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "reason" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + + Examples: + | service_type | + | Blood Pressure | + | Contraception | + + @complete @reporting + Scenario Outline: F007SX10 Check for missing dos service type without bp and contraception + Given a pharmacy service is created with "6" character odscode and type "" + And the change event has a palliative care entry + When the Changed Event is sent for processing with "valid" api key + Then the "service-matcher" lambda shows field "report_key" with value "MISSING_SERVICE_TYPE" + And "ods_code" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "org_type" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "org_sub_type" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "nhsuk_organisation_status" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "dos_missing_service_type" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "dos_service_address" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "dos_service_postcode" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "nhsuk_parent_organisation_name" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "dos_region" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + And "reason" attribute is identified in the "MISSING_SERVICE_TYPE" report in "service-matcher" logs + + Examples: + | service_type | + | 13 | + | 131 | + | 132 | + | 134 | + | 137 | + | 148 | + | 149 | diff --git a/test/integration/features/F008_Quality_Checker.feature b/test/integration/features/F008_Quality_Checker.feature new file mode 100644 index 000000000..d01f307f2 --- /dev/null +++ b/test/integration/features/F008_Quality_Checker.feature @@ -0,0 +1,91 @@ +Feature: F008. Check DoS data quality + + @complete @slack_and_infrastructure + Scenario Outline: F008SX01. Check for too many services + Given services of type for an odscode starting with A + When the quality checker is run + Then the following is reported times + + Examples: + | service_count | service_status | service_type | reason | + | 2 | active | 148 | Multiple 'Pharmacy' type services found (type 148) | + | 2 | active | 149 | Multiple 'Pharmacy' type services found (type 149) | + + @complete @slack_and_infrastructure + Scenario Outline: F008SX02. Check for not too many services + Given services of type for an odscode starting with A + When the quality checker is run + Then the following is not reported + + Examples: + | service_count | service_status | service_type | reason | + | 2 | closed | 148 | Multiple 'Pharmacy' type services found (type 148) | + | 2 | commissioning | 148 | Multiple 'Pharmacy' type services found (type 148) | + | 2 | closed | 149 | Multiple 'Pharmacy' type services found (type 149) | + | 2 | commissioning | 149 | Multiple 'Pharmacy' type services found (type 149) | + + @complete @slack_and_infrastructure + Scenario Outline: F008SX03. Palliative on correct service type with incorrect odscode length + Given an active service of type for a character odscode starting with A + And the service in DoS supports palliative care + When the quality checker is run + Then the following is reported 1 times with a long odscode + + Examples: + | service_type | character_count | reason | + | 13 | 6 | Palliative Care ZCode is on the correct service type, but the service is incorrectly profiled | + + + @complete @slack_and_infrastructure + Scenario Outline: F008SX04. Blood Pressure/Contraception/Palliative Care on a non-blood pressure/non-contraception/non-palliative care service type does report + Given services of type for an odscode starting with A + And the DoS service has Z code + When the quality checker is run + Then the following is reported times + + Examples: + | commissioned_service | service_type | service_count | service_status | reason | + | blood pressure | 13 | 1 | active | Blood Pressure ZCode is on invalid service type | + | blood pressure | 131 | 1 | active | Blood Pressure ZCode is on invalid service type | + | blood pressure | 132 | 1 | active | Blood Pressure ZCode is on invalid service type | + | blood pressure | 134 | 1 | active | Blood Pressure ZCode is on invalid service type | + | blood pressure | 137 | 1 | active | Blood Pressure ZCode is on invalid service type | + | blood pressure | 149 | 1 | active | Blood Pressure ZCode is on invalid service type | + | contraception | 13 | 1 | active | Contraception ZCode is on invalid service type | + | contraception | 131 | 1 | active | Contraception ZCode is on invalid service type | + | contraception | 132 | 1 | active | Contraception ZCode is on invalid service type | + | contraception | 134 | 1 | active | Contraception ZCode is on invalid service type | + | contraception | 137 | 1 | active | Contraception ZCode is on invalid service type | + | contraception | 148 | 1 | active | Contraception ZCode is on invalid service type | + | palliative care | 131 | 1 | active | Palliative Care ZCode is on invalid service type | + | palliative care | 132 | 1 | active | Palliative Care ZCode is on invalid service type | + | palliative care | 134 | 1 | active | Palliative Care ZCode is on invalid service type | + | palliative care | 137 | 1 | active | Palliative Care ZCode is on invalid service type | + | palliative care | 148 | 1 | active | Palliative Care ZCode is on invalid service type | + | palliative care | 149 | 1 | active | Palliative Care ZCode is on invalid service type | + + + @complete @slack_and_infrastructure + Scenario Outline: F008SX05. Blood Pressure/Contraception on a blood pressure/contraception service type does not report + Given services of type for an odscode starting with A + And the DoS service has Z code + When the quality checker is run + Then the following is not reported + + Examples: + | commissioned_service | service_type | service_count | service_status | reason | + | blood pressure | 148 | 1 | active | Blood Pressure ZCode is on invalid service type | + | contraception | 149 | 1 | active | Contraception ZCode is on invalid service type | + | palliative care | 13 | 1 | active | Palliative Care ZCode is on invalid service type | + + + @complete @slack_and_infrastructure + Scenario Outline: F008SX06. Palliative on correct service type with correct odscode length does not report + Given an active service of type for a character odscode starting with A + And the service in DoS supports palliative care + When the quality checker is run + Then the following is not reported + + Examples: + | service_type | character_count | reason | + | 13 | 5 | Palliative Care ZCode is on the correct service type, but the service is incorrectly profiled | diff --git a/test/integration/resources/payloads/expected_schema.json b/test/integration/resources/payloads/expected_schema.json deleted file mode 100644 index ad157ddc3..000000000 --- a/test/integration/resources/payloads/expected_schema.json +++ /dev/null @@ -1,164 +0,0 @@ -{ - "description": "Default valid schema payload", - "SearchKey": "X3618", - "ODSCode": "FD189", - "OrganisationName": "Test Pharmacy", - "OrganisationTypeId": "PHA", - "OrganisationType": "Pharmacy", - "OrganisationSubType": "Community", - "OrganisationStatus": "Visible", - "Address1": "501-503 MANSFIELD ROAD", - "Address2": null, - "Address3": null, - "City": "NOTTINGHAM", - "County": null, - "Latitude": 53.3723793029785, - "Longitude": -2.12734055519104, - "Postcode": "NG5 2JJ", - "ParentOrganisation": { - "ODSCode": "QOP", - "OrganisationName": "Greater Manchester Health and Social Care Partnership (STP)" - }, - "OpeningTimes": [ - { - "Weekday": "Monday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Tuesday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Wednesday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Thursday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Friday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Saturday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Sunday", - "OpeningTime": "10:00", - "ClosingTime": "17:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "", - "OpeningTime": "10:00", - "ClosingTime": "17:00", - "OffsetOpeningTime": 0, - "OffsetClosingTime": 0, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Dec 24 2022", - "IsOpen": true - }, - { - "Weekday": "", - "OpeningTime": "10:00", - "ClosingTime": "17:00", - "OffsetOpeningTime": 0, - "OffsetClosingTime": 0, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": "Dec 25 2022", - "IsOpen": true - } - ], - "Contacts": [ - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": "http://www.rowlandspharmacy.co.uk/" - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": "0161 4838720" - } - ], - "Facilities": [ - { - "Id": 1, - "Value": "No" - }, - { - "Id": 2, - "Value": "Yes" - }, - { - "Id": 3, - "Value": "No" - }, - { - "Id": 4, - "Value": "Yes" - }, - { - "Id": 5, - "Value": "No" - }, - { - "Id": 6, - "Value": "No" - }, - { - "Id": 35, - "Value": "No" - } - ], - "LastUpdatedDates": { - "OpeningTimes": "2021-09-07T10:21:30+00:00", - "Facilities": "2021-09-07T10:21:42+00:00", - "Services": "2021-09-07T10:21:36+00:00", - "ContactDetails": "2017-10-23T14:06:46+00:00" - } -} diff --git a/test/integration/steps/conftest.py b/test/integration/steps/conftest.py new file mode 100644 index 000000000..66485baed --- /dev/null +++ b/test/integration/steps/conftest.py @@ -0,0 +1,46 @@ +import pytest + +from .functions.context import Context +from .functions.dos.set_up_data import ( + set_up_blood_pressure_z_code_in_db, + set_up_common_condition_service_types, + set_up_contraception_z_code_in_db, + set_up_palliative_care_z_code_in_db, +) + + +def log_test_summary(step, request) -> None: + """Log test summary.""" + print(f"Step Summary: For {step}") + try: + print(request.getfixturevalue("context")) + except Exception: + print("No context") + + +def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args, exception) -> None: + """Called after a step function raised an exception.""" + log_test_summary(step, request) + + +def pytest_bdd_after_step(request, feature, scenario, step, step_func, step_func_args) -> None: + """Called after a step function call.""" + log_test_summary(step, request) + + +@pytest.fixture(autouse=True) +def context() -> Context: + """Fixture to create a context object for each test. + + Returns: + Context: Context object. + """ + return Context() + + +def pytest_sessionstart(session) -> None: + """Called after the Session object has been created and before performing collection and entering the run test loop.""" # noqa: E501 + set_up_palliative_care_z_code_in_db() + set_up_blood_pressure_z_code_in_db() + set_up_contraception_z_code_in_db() + set_up_common_condition_service_types() diff --git a/build/docker/orchestrator/assets/.gitkeep b/test/integration/steps/functions/__init__.py similarity index 100% rename from build/docker/orchestrator/assets/.gitkeep rename to test/integration/steps/functions/__init__.py diff --git a/test/integration/steps/functions/api.py b/test/integration/steps/functions/api.py new file mode 100644 index 000000000..63026856c --- /dev/null +++ b/test/integration/steps/functions/api.py @@ -0,0 +1,65 @@ +from json import dumps, loads +from os import getenv +from typing import Any + +from requests import Response, post + +from .aws.secrets_manager import get_secret +from .context import Context +from .utils import generate_unique_sequence_number + + +def process_payload(context: Context, valid_api_key: bool | None, correlation_id: str) -> Response: + """Process payload. + + Args: + context (Context): Test context. + valid_api_key (bool | None): Valid api key. + correlation_id (str): Correlation id. + + Raises: + ValueError: Unable to process change request payload. + + Returns: + Response: Response from the API. + """ + api_key = "invalid" + if valid_api_key: + api_key = loads(get_secret(getenv("API_KEY_SECRET")))[getenv("NHS_UK_API_KEY")] + sequence_number = generate_unique_sequence_number(context.change_event["ODSCode"]) + headers = { + "x-api-key": api_key, + "sequence-number": sequence_number, + "correlation-id": correlation_id, + "Content-Type": "application/json", + } + payload = context.change_event + output = post(url=getenv("HTTPS_DOS_INTEGRATION_URL"), headers=headers, data=dumps(payload), timeout=10) + if valid_api_key and output.status_code != 200: + msg = f"Unable to process change request payload. Error: {output.text}" + raise ValueError(msg) + return output + + +def process_payload_with_sequence(context: Context, correlation_id: str, sequence_id: Any) -> Response: + """Process payload with sequence. + + Args: + context (Context): Test context. + correlation_id (str): Correlation id. + sequence_id (Any): Sequence id. + """ + api_key = loads(get_secret(getenv("API_KEY_SECRET")))[getenv("NHS_UK_API_KEY")] + headers = { + "x-api-key": api_key, + "correlation-id": correlation_id, + "Content-Type": "application/json", + } + if sequence_id is not None: + headers["sequence-number"] = str(sequence_id) + payload = context.change_event + output = post(url=getenv("HTTPS_DOS_INTEGRATION_URL"), headers=headers, data=dumps(payload), timeout=10) + if output.status_code != 200 and isinstance(sequence_id, int): + msg = f"Unable to process change request payload. Error: {output.text}" + raise ValueError(msg) + return output diff --git a/test/integration/steps/functions/assertions.py b/test/integration/steps/functions/assertions.py new file mode 100644 index 000000000..4d38b2e68 --- /dev/null +++ b/test/integration/steps/functions/assertions.py @@ -0,0 +1,58 @@ +def assert_standard_closing(dos_times: list, ce_times: list[dict]) -> int: + """Function to assert standard closing times changes. + + Args: + dos_times (list): The times pulled from DOS + ce_times (list): The times pulled from the change event to compare too + + Returns: + int: If more than 0 assertions are made then the test passes. + """ + counter = 0 + for entry in ce_times: + if entry["times"] == "closed": + currentday = entry["name"] + for dates in dos_times: + if currentday == next(iter(dates.keys())): + assert dates[currentday]["changetype"] == "delete", "Open when expected closed" + assert ( + "add" not in dates[currentday]["data"] + ), "ERROR: Unexpected add field found in service history" + counter += 1 + return counter + + +def assert_standard_openings( + change_type: str, + dos_times: list[dict], + ce_times: list[dict], + strict: bool | None = None, +) -> int: + """Function to assert standard opening times changes. Added to remove complexity for sonar. + + Args: + change_type (Str): The type of change being asserted + dos_times (list[Dict]): The times pulled from DOS + ce_times (Dict): The times pulled from the change event to compare too + strict (bool | None): If true, will assert that the changetype is the same as the one passed in + + Returns: + counter (Int): The amount of assertions made + """ + counter = 0 + valid_change_types = ["add", "modify"] + for entry in dos_times: + currentday = next(iter(entry.keys())) + for dates in ce_times: + if dates["name"] == currentday: + assert entry[currentday]["data"]["add"][0] == dates["times"], "ERROR: Dates do not match" + if strict: + assert entry[currentday]["changetype"] == change_type, "ERROR: Incorrect changetype" + else: + assert entry[currentday]["changetype"] in valid_change_types, "ERROR: Incorrect changetype" + if entry[currentday]["changetype"] == "add": + assert "remove" not in entry[currentday]["data"], "ERROR: Remove is present in service history" + elif entry[currentday]["changetype"] == "modify": + assert "remove" in entry[currentday]["data"], f"ERROR: Remove is not present for {currentday}" + counter += 1 + return counter diff --git a/build/docker/serverless/assets/.gitignore b/test/integration/steps/functions/aws/__init__.py similarity index 100% rename from build/docker/serverless/assets/.gitignore rename to test/integration/steps/functions/aws/__init__.py diff --git a/test/integration/steps/functions/aws/aws_lambda.py b/test/integration/steps/functions/aws/aws_lambda.py new file mode 100644 index 000000000..06e9dfcfb --- /dev/null +++ b/test/integration/steps/functions/aws/aws_lambda.py @@ -0,0 +1,84 @@ +from json import dumps +from os import getenv +from random import randint +from time import sleep +from typing import Any + +from boto3 import client +from botocore import config as botocore_config +from botocore.exceptions import ConnectionClosedError +from requests import Response + +cfg = botocore_config.Config( + retries={"max_attempts": 0}, + read_timeout=900, + connect_timeout=900, +) +LAMBDA_CLIENT_FUNCTIONS = client("lambda", config=cfg) + + +def invoke_dos_db_handler_lambda(lambda_payload: dict) -> Any: + """Invoke dos db handler lambda. + + Args: + lambda_payload (dict): Lambda payload. + + Returns: + Any: Lambda response. + """ + response_status = False + response = None + retries = 0 + while not response_status: + try: + response: Any = LAMBDA_CLIENT_FUNCTIONS.invoke( + FunctionName=getenv("DOS_DB_HANDLER_LAMBDA"), + InvocationType="RequestResponse", + Payload=dumps(lambda_payload), + ) + response_payload = response["Payload"].read().decode("utf-8") + if "errorMessage" not in response_payload: + return response_payload + + if retries > 6: + msg = f"Unable to run dos db handler lambda successfully after {retries} retries, {response_payload}" + raise ValueError(msg) # noqa: TRY301, RUF100 + + except ConnectionClosedError: + sleep(60) + + retries += 1 + sleep(randint(5, 15)) + return None + + +def invoke_quality_checker_lambda() -> Response: + """Invoke quality checker lambda. + + Returns: + Response: Lambda response. + """ + return LAMBDA_CLIENT_FUNCTIONS.invoke( + FunctionName=getenv("QUALITY_CHECKER_LAMBDA"), + InvocationType="RequestResponse", + Payload="{}", + ) + + +def re_process_payload(odscode: str, seq_number: str) -> str: + """Reprocesses a payload from the event replay lambda. + + Args: + odscode (str): Odscode to send to lambda + seq_number (str): Sequence number to send to lambda + + Returns: + str: Response from lambda + """ + lambda_payload = {"odscode": odscode, "sequence_number": seq_number} + response = LAMBDA_CLIENT_FUNCTIONS.invoke( + FunctionName=getenv("EVENT_REPLAY_LAMBDA"), + InvocationType="RequestResponse", + Payload=dumps(lambda_payload), + ) + return response["Payload"].read().decode("utf-8") diff --git a/test/integration/steps/functions/aws/cloudwatch.py b/test/integration/steps/functions/aws/cloudwatch.py new file mode 100644 index 000000000..a18ac31c1 --- /dev/null +++ b/test/integration/steps/functions/aws/cloudwatch.py @@ -0,0 +1,121 @@ +import random +from datetime import datetime +from json import dumps +from os import getenv +from sqlite3 import Timestamp +from time import sleep + +from boto3 import client +from botocore.exceptions import ClientError +from pytz import timezone + +LAMBDA_CLIENT_LOGS = client("logs") + + +def get_logs( + query: str, + lambda_name: str, + start_time: Timestamp | None = None, + retry_count: int = 32, # Retry Count + sleep_per_loop: int = 20, # Sleep time between retries. +) -> str: + """Get logs from CloudWatch. + + Args: + query (str): CloudWatch Logs Insights query + lambda_name (str): Lambda logs to search + start_time (Timestamp): Start time for the query + retry_count (int, optional): Retries for the query. Defaults to 32. + sleep_per_loop (int, optional): Sleep time between retries. Defaults to 20. + + Returns: + str: CloudWatch Logs Insights query response + """ + log_group_name = get_log_group_name(lambda_name) + logs_found = False + counter = 0 + limit_exceeded_timeout = 0 + while not logs_found: + try: + start_query_response = LAMBDA_CLIENT_LOGS.start_query( + logGroupName=log_group_name, + startTime=int(start_time), + endTime=int(datetime.now(timezone("Europe/London")).timestamp()), + queryString=query, + ) + except ClientError as error: + if error.response["Error"]["Code"] == "LimitExceededException": + limit_exceeded_timeout += random.uniform(30, 60) * (2**counter) # Exponential backoff with jitter + print(f"Limit exceeded, retrying after {limit_exceeded_timeout} seconds.") + sleep(limit_exceeded_timeout) + continue + raise + query_id = start_query_response["queryId"] + response = None + + # Wait for the query to complete without a max timeout + while response is None or response["status"] != "Complete": + sleep(sleep_per_loop) + response = LAMBDA_CLIENT_LOGS.get_query_results(queryId=query_id) + + counter += 1 + if response["results"] != []: + logs_found = True + elif counter == retry_count: + print(f"Log search retries exceeded.. no logs found for query: {query}") + msg = "Log search retries exceeded.. no logs found" + raise ValueError(msg) + return dumps(response, indent=2) + + +def negative_log_check(query: str, event_lambda: str, start_time: Timestamp) -> bool: + """Check logs don't exist. + + Args: + query (str): CloudWatch Logs Insights query + event_lambda (str): Lambda logs to search + start_time (Timestamp): Start time for the query + + Returns: + bool: True if no logs found + """ + log_group_name = get_log_group_name(event_lambda) + logs_found = False + limit_exceeded_timeout = 0 + while not logs_found: + try: + start_query_response = LAMBDA_CLIENT_LOGS.start_query( + logGroupName=log_group_name, + startTime=int(start_time), + endTime=int(datetime.now(timezone("Europe/London")).timestamp()), + queryString=query, + ) + except ClientError as error: + if error.response["Error"]["Code"] == "LimitExceededException": + limit_exceeded_timeout += 30 + sleep(limit_exceeded_timeout) + continue + raise + logs_found = True + + query_id = start_query_response["queryId"] + sleep(30) + response = LAMBDA_CLIENT_LOGS.get_query_results(queryId=query_id) + + if response["results"] == []: + return True + + msg = "Matching logs have been found" + raise ValueError(msg) + + +def get_log_group_name(lambda_name: str) -> str: + """Get the log group name for a lambda. + + Args: + lambda_name (str): Lambda name + + Returns: + str: Log group name + """ + return f'/aws/lambda/uec-dos-int-{getenv("BLUE_GREEN_ENVIRONMENT")}-{lambda_name}' diff --git a/test/integration/steps/functions/aws/dynamodb.py b/test/integration/steps/functions/aws/dynamodb.py new file mode 100644 index 000000000..ba2bc9ed4 --- /dev/null +++ b/test/integration/steps/functions/aws/dynamodb.py @@ -0,0 +1,78 @@ +from decimal import Decimal +from os import getenv + +from boto3 import client +from boto3.dynamodb.types import TypeDeserializer + +DYNAMO_DB_TABLE = getenv("DYNAMO_DB_TABLE") +DYNAMO_CLIENT = client("dynamodb") + + +def get_stored_events_from_dynamo_db(odscode: str, sequence_number: Decimal) -> dict: + """Get stored events from dynamodb. + + Args: + odscode (str): ODSCode. + sequence_number (Decimal): Sequence number. + + Raises: + ValueError: No event found in dynamodb for ODSCode {odscode} and SequenceNumber {sequence_number}. + + Returns: + dict: Stored events from dynamodb. + """ + resp = DYNAMO_CLIENT.query( + TableName=DYNAMO_DB_TABLE, + IndexName="gsi_ods_sequence", + ProjectionExpression="ODSCode,SequenceNumber", + ExpressionAttributeValues={ + ":v1": { + "S": odscode, + }, + ":v2": { + "N": str(sequence_number), + }, + }, + KeyConditionExpression="ODSCode = :v1 and SequenceNumber = :v2", + Limit=1, + ScanIndexForward=False, + ) + if len(resp["Items"]) == 0: + msg = f"No event found in dynamodb for ODSCode {odscode} and SequenceNumber {sequence_number}" + raise ValueError(msg) + item = resp["Items"][0] + deserializer = TypeDeserializer() + return {k: deserializer.deserialize(v) for k, v in item.items()} + + +def get_latest_sequence_id_for_a_given_odscode(odscode: str) -> int: + """Get latest sequence id for a given odscode from dynamodb. + + Args: + odscode (str): ODSCode. + + Raises: + Exception: Unable to get sequence id from dynamodb + + Returns: + int: Latest sequence id for a given odscode from dynamodb. + """ + try: + resp = DYNAMO_CLIENT.query( + TableName=DYNAMO_DB_TABLE, + IndexName="gsi_ods_sequence", + KeyConditionExpression="ODSCode = :odscode", + ExpressionAttributeValues={ + ":odscode": {"S": odscode}, + }, + Limit=1, + ScanIndexForward=False, + ProjectionExpression="ODSCode,SequenceNumber", + ) + sequence_number = 0 + if resp.get("Count") > 0: + sequence_number = int(resp.get("Items")[0]["SequenceNumber"]["N"]) + except Exception as err: + print(f"Unable to get sequence id from dynamodb for a given ODSCode {odscode} {DYNAMO_DB_TABLE} .Error: {err}") + raise + return sequence_number diff --git a/test/integration/steps/functions/aws/s3.py b/test/integration/steps/functions/aws/s3.py new file mode 100644 index 000000000..1a8bad50d --- /dev/null +++ b/test/integration/steps/functions/aws/s3.py @@ -0,0 +1,32 @@ +from json import load +from os import getenv, remove +from time import sleep + +from boto3 import client, resource + +from integration.steps.functions.context import Context + +S3_CLIENT = client("s3", region_name="eu-west-2") + + +def get_s3_email_file(context: Context) -> Context: + """Get the email file from S3 bucket. + + Args: + context (Context): Test context + + Returns: + context (Context): Test context + """ + sleep(45) + email_file_name = "email_file.json" + shared_environment = getenv("SHARED_ENVIRONMENT") + bucket_name = f"uec-dos-int-{shared_environment}-send-email-bucket" + response = S3_CLIENT.list_objects(Bucket=bucket_name) + object_key = response["Contents"][-1]["Key"] + s3_resource = resource("s3") + s3_resource.meta.client.download_file(bucket_name, object_key, email_file_name) + with open(email_file_name) as email_file: + context.other = load(email_file) + remove("./email_file.json") + return context diff --git a/test/integration/steps/functions/aws/secrets_manager.py b/test/integration/steps/functions/aws/secrets_manager.py new file mode 100644 index 000000000..c9e08eab6 --- /dev/null +++ b/test/integration/steps/functions/aws/secrets_manager.py @@ -0,0 +1,15 @@ +from boto3 import client + + +def get_secret(secret_name: str) -> str: + """Get secret from AWS Secrets Manager. + + Args: + secret_name (str): Get secret from AWS Secrets Manager. + + Returns: + str: Secret value. + """ + secrets_manager = client(service_name="secretsmanager") + get_secret_value_response = secrets_manager.get_secret_value(SecretId=secret_name) + return get_secret_value_response["SecretString"] diff --git a/test/integration/steps/functions/aws/sqs.py b/test/integration/steps/functions/aws/sqs.py new file mode 100644 index 000000000..622673673 --- /dev/null +++ b/test/integration/steps/functions/aws/sqs.py @@ -0,0 +1,129 @@ +from json import dumps +from os import getenv +from random import randint +from time import time_ns + +from boto3 import client + +from integration.steps.functions.context import Context + +SQS_CLIENT = client("sqs", region_name="eu-west-2") + + +def get_sqs_queue_name(queue_type: str) -> str: + """Returns the SQS queue name for the specified queue type. + + Args: + queue_type (str): The type of SQS queue to return + + Returns: + queue_name (str): The name of the SQS queue + """ + response = "" + blue_green_environment = getenv("BLUE_GREEN_ENVIRONMENT") + shared_environment = getenv("SHARED_ENVIRONMENT") + match queue_type.lower(): + case "changeevent": + response = SQS_CLIENT.get_queue_url( + QueueName=f"uec-dos-int-{shared_environment}-change-event-dead-letter-queue.fifo", + ) + case "updaterequest": + response = SQS_CLIENT.get_queue_url( + QueueName=f"uec-dos-int-{blue_green_environment}-update-request-dead-letter-queue.fifo", + ) + case "updaterequestfail": + response = SQS_CLIENT.get_queue_url( + QueueName=f"uec-dos-int-{blue_green_environment}-update-request-queue.fifo", + ) + case _: + msg = "Invalid SQS queue type specified" + raise ValueError(msg) + + return response["QueueUrl"] + + +def post_ur_sqs() -> None: + """Post to update request SQS queue.""" + queue_url = get_sqs_queue_name("updaterequest") + sqs_body = generate_sqs_body("https://www.test.com") + + SQS_CLIENT.send_message( + QueueUrl=queue_url, + MessageBody=dumps(sqs_body), + MessageDeduplicationId=str(randint(10000, 99999)), + MessageGroupId=str(randint(10000, 99999)), + MessageAttributes=get_sqs_message_attributes(), + ) + + +def post_ur_fifo() -> None: + """Post to update request FIFO queue.""" + queue_url = get_sqs_queue_name("updaterequestfail") + sqs_body = generate_sqs_body("abc@def.com") + + SQS_CLIENT.send_message( + QueueUrl=queue_url, + MessageBody=dumps(sqs_body), + MessageDeduplicationId=str(randint(10000, 99999)), + MessageGroupId=str(randint(10000, 99999)), + MessageAttributes=get_sqs_message_attributes(), + ) + + +def post_to_change_event_dlq(context: Context) -> None: + """Post to change event DLQ. + + Args: + context (Context): Test context + """ + queue_url = get_sqs_queue_name("changeevent") + sqs_body = context.change_event + + SQS_CLIENT.send_message( + QueueUrl=queue_url, + MessageBody=dumps(sqs_body), + MessageDeduplicationId=str(randint(10000, 99999)), + MessageGroupId=str(randint(10000, 99999)), + MessageAttributes=get_sqs_message_attributes(context.change_event["ODSCode"]), + ) + + +def get_sqs_message_attributes(odscode: str = "FW404") -> dict: + """Generates a random set of message attributes for SQS. + + Args: + odscode (str, optional): odscode to be added to message attributes. Defaults to "FW404". + + Returns: + dict: message attributes + """ + return { + "correlation_id": {"DataType": "String", "StringValue": f"sqs-injection-id-{randint(0,1000)}"}, + "message_received": {"DataType": "Number", "StringValue": str(randint(1000, 5000))}, + "message_group_id": {"DataType": "Number", "StringValue": str(randint(1000, 5000))}, + "message_deduplication_id": {"DataType": "String", "StringValue": str(randint(1000, 99999))}, + "dynamo_record_id": {"DataType": "String", "StringValue": "78adf177e2cd469318e854e4e8068dd4"}, + "ods_code": {"DataType": "String", "StringValue": odscode}, + "error_msg": {"DataType": "String", "StringValue": "error_message"}, + "error_msg_http_code": {"DataType": "String", "StringValue": "404"}, + "sequence-number": {"DataType": "Number", "StringValue": str(time_ns())}, + } + + +def generate_sqs_body(website: str) -> dict: + """Generate SQS body. + + Args: + website (str): Website to update. + + Returns: + dict: SQS body. + """ + return { + "reference": "14451_1657015307500997089_//www.test.com]", + "system": "DoS Integration", + "message": "DoS Integration CR. correlation-id: 14451_1657015307500997089_//www.test.com]", + "replace_opening_dates_mode": True, + "service_id": "22963", + "changes": {"website": website}, + } diff --git a/test/integration/steps/functions/constants.py b/test/integration/steps/functions/constants.py new file mode 100644 index 000000000..aa3d9e215 --- /dev/null +++ b/test/integration/steps/functions/constants.py @@ -0,0 +1,26 @@ +PHARMACY_ORG_TYPE_ID = "PHA" +# DoS Service Table Field Names +DOS_POSTCODE_FIELD_NAME = "postcode" +DOS_PUBLIC_PHONE_FIELD_NAME = "publicphone" +DOS_WEB_FIELD_NAME = "web" +DOS_ADDRESS_FIELD_NAME = "address" +DOS_TOWN_FIELD_NAME = "town" +DOS_TOWN_EASTING_NAME = "easting" +DOS_TOWN_NORTHING_NAME = "northing" +DOS_TOWN_LATITUDE_NAME = "latitude" +DOS_TOWN_LONGITUDE_NAME = "longitude" +DOS_STATUS_ID_FIELD_NAME = "statusid" +# DoS Service History Table Key Names +DOS_POSTCODE_SERVICE_HISTORY_KEY = "postalcode" +DOS_PUBLIC_PHONE_SERVICE_HISTORY_KEY = "cmstelephoneno" +DOS_WEB_SERVICE_HISTORY_KEY = "cmsurl" +DOS_ADDRESS_SERVICE_HISTORY_KEY = "postaladdress" +DOS_ZCODE_SERVICE_HISTORY_KEY = "cmssgsdid" +DOS_SPECIFIED_OPENING_TIMES_SERVICE_HISTORY_KEY = "cmsopentimespecified" +DOS_STANDARD_OPENING_TIMES_MONDAY_SERVICE_HISTORY_KEY = "cmsopentimemonday" +DOS_STANDARD_OPENING_TIMES_TUESDAY_SERVICE_HISTORY_KEY = "cmsopentimetuesday" +DOS_STANDARD_OPENING_TIMES_WEDNESDAY_SERVICE_HISTORY_KEY = "cmsopentimewednesday" +DOS_STANDARD_OPENING_TIMES_THURSDAY_SERVICE_HISTORY_KEY = "cmsopentimethursday" +DOS_STANDARD_OPENING_TIMES_FRIDAY_SERVICE_HISTORY_KEY = "cmsopentimefriday" +DOS_STANDARD_OPENING_TIMES_SATURDAY_SERVICE_HISTORY_KEY = "cmsopentimesaturday" +DOS_STANDARD_OPENING_TIMES_SUNDAY_SERVICE_HISTORY_KEY = "cmsopentimesunday" diff --git a/test/integration/steps/functions/context.py b/test/integration/steps/functions/context.py new file mode 100644 index 000000000..1eabdf18e --- /dev/null +++ b/test/integration/steps/functions/context.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from typing import Self + +from requests import Response + + +@dataclass(init=True) +class Context: + """Test context object for storing data between steps.""" + + change_event: dict | None = None + service_id: str | None = None + service_uid: str | None = None + correlation_id: str | None = None + response: Response | None = None + sequence_number: int | None = None + start_time: str | None = None + previous_value: str = "unknown" + ods_code: str | None = None + website: str | None = None + phone: str | None = None + other: dict | None = None + generator_data: dict | None = None + # Other used as a catch all for any other data that is not covered by the above and only used in a couple tests + + def __repr__(self: Self) -> str: + """Return a string representation of the object. + + Returns: + str: String representation of the object. + """ + return ( + f"Context(correlation_id={self.correlation_id}, sequence_number={self.sequence_number}" + f", service_id={self.service_id}, previous_value={self.previous_value}, change_event={self.change_event}" + f", other={self.other}, service_uid={self.service_uid}, website={self.website}, phone={self.phone}" + f", query={self.generator_data}" + ) diff --git a/build/docker/slack-messenger/assets/.gitkeep b/test/integration/steps/functions/dos/__init__.py similarity index 100% rename from build/docker/slack-messenger/assets/.gitkeep rename to test/integration/steps/functions/dos/__init__.py diff --git a/test/integration/steps/functions/dos/check_data.py b/test/integration/steps/functions/dos/check_data.py new file mode 100644 index 000000000..506dc3a0d --- /dev/null +++ b/test/integration/steps/functions/dos/check_data.py @@ -0,0 +1,96 @@ +from typing import Any + +from .get_data import get_service_history +from .translation import get_service_history_data_key +from integration.steps.functions.aws.aws_lambda import invoke_dos_db_handler_lambda +from integration.steps.functions.utils import check_recent_event + + +def check_pending_service_is_rejected(service_id: str) -> Any: + """Check pending service is rejected. + + Args: + service_id (str): Service id. + + Returns: + Any: Pending service is rejected. + """ + query = "SELECT approvestatus FROM changes WHERE serviceid = %(SERVICE_ID)s" + query_vars = {"SERVICE_ID": service_id} + lambda_payload = {"type": "read", "query": query, "query_vars": query_vars} + return invoke_dos_db_handler_lambda(lambda_payload) + + +def check_service_history( + service_id: str, + plain_english_field_name: str, + expected_data: Any, + previous_data: Any, +) -> None: + """Check the service history for the expected data and previous data is removed.""" + service_history = get_service_history(service_id) + first_key_in_service_history = next(iter(service_history.keys())) + changes = service_history[first_key_in_service_history]["new"] + change_key = get_service_history_data_key(plain_english_field_name) + if change_key not in changes: + msg = f"DoS Change key '{change_key}' not found in latest service history entry" + raise ValueError(msg) + + assert ( + expected_data == changes[change_key]["data"] + ), f"Expected data: {expected_data}, Expected data type: {type(expected_data)}, Actual data: {changes[change_key]['data']}" # noqa: E501 + + if "previous" in changes[change_key] and previous_data != "unknown": + if previous_data != "": # noqa: PLC1901, RUF100 + assert changes[change_key]["previous"] == str( + previous_data, + ), f"Expected previous data: {previous_data}, Actual data: {changes[change_key]}" + else: + assert ( + changes[change_key]["previous"] is None + ), f"Expected previous data: {previous_data}, Actual data: {changes[change_key]}" + + +def service_history_negative_check(service_id: str) -> str: + """Check the service history for the expected data and previous data is removed. + + Args: + service_id (str): Service ID of the service to be checked + + Returns: + str: Returns a string based on the result of the check + """ + service_history = get_service_history(service_id) + if service_history == []: + return "Not Updated" + + first_key_in_service_history = next(iter(service_history.keys())) + if check_recent_event(first_key_in_service_history) is False: + return "Not Updated" + return "Updated" + + +def check_service_history_change_type(service_id: str, change_type: str, field_name: None | str = None) -> str: + """Check the service history for the expected change type. + + Args: + service_id (str): Service ID of the service to be checked + change_type (str): Change type to be checked + field_name (str, optional): Field to search for in history. Defaults to None. + + Returns: + str: Returns a string based on the result of the check + """ + service_history = get_service_history(service_id) + first_key_in_service_history = next(iter(service_history.keys())) + if field_name is None: + change_status = service_history[first_key_in_service_history]["new"][ + next(iter(service_history[first_key_in_service_history]["new"].keys())) + ]["changetype"] + else: + change_status = service_history[first_key_in_service_history]["new"][field_name]["changetype"] + if check_recent_event(first_key_in_service_history): + if change_status == change_type or change_type == "modify" and change_status == "add": + return "Change type matches" + return "Change type does not match" + return "No changes have been made" diff --git a/test/integration/steps/functions/dos/get_data.py b/test/integration/steps/functions/dos/get_data.py new file mode 100644 index 000000000..55e0764c9 --- /dev/null +++ b/test/integration/steps/functions/dos/get_data.py @@ -0,0 +1,252 @@ +from ast import literal_eval +from datetime import datetime, timedelta +from json import loads +from time import sleep +from typing import Any + +from pytz import UTC, timezone + +from integration.steps.functions.aws.aws_lambda import invoke_dos_db_handler_lambda + + +def wait_for_service_update(service_id: str) -> Any: + """Wait for the service to be updated by checking modifiedtime.""" + for _ in range(12): + sleep(10) + updated_date_time_str: str = get_service_table_field(service_id, "modifiedtime") + updated_date_time = datetime.strptime(updated_date_time_str, "%Y-%m-%d %H:%M:%S%z") + updated_date_time = updated_date_time.replace(tzinfo=UTC) + two_mins_ago = datetime.now(tz=timezone("Europe/London")) - timedelta(minutes=2) + two_mins_ago = two_mins_ago.replace(tzinfo=UTC) + if updated_date_time > two_mins_ago: + break + else: + msg = f"Service not updated, service_id: {service_id}" + raise ValueError(msg) + + +def get_locations_table_data(postcode: str) -> list: + """Get locations table data. + + Args: + postcode (str): Postcode to search for in locations table. + + Returns: + list: Locations table data. + """ + query = ( + "SELECT postaltown as town, postcode, easting, northing, latitude, longitude " + "FROM locations WHERE postcode = %(POSTCODE)s" + ) + query_vars = {"POSTCODE": postcode} + lambda_payload = {"type": "read", "query": query, "query_vars": query_vars} + response = invoke_dos_db_handler_lambda(lambda_payload) + return loads(loads(response)) + + +def get_services_table_location_data(service_id: str) -> list: + """Get services table location data. + + Args: + service_id (str): Service id to search for in services table. + + Returns: + list: Services table location data. + """ + query = "SELECT town, postcode, easting, northing, latitude, longitude FROM services WHERE id = %(SERVICE_ID)s" + query_vars = {"SERVICE_ID": service_id} + lambda_payload = {"type": "read", "query": query, "query_vars": query_vars} + response = invoke_dos_db_handler_lambda(lambda_payload) + return loads(loads(response)) + + +def get_service_id(odscode: str, typeid: int = 13) -> str: + """Get service id. + + Args: + odscode (str): ODSCode. + typeid (int, optional): Type ID. Defaults to 13. If not provided, the default value is 13. + + Returns: + str: Service id. + """ + data = [] + query = f"SELECT id FROM services WHERE typeid = {typeid} AND statusid = 1 AND odscode like '{odscode}%' LIMIT 1" # noqa: S608 + for _ in range(16): + lambda_payload = {"type": "read", "query": query, "query_vars": None} + response = invoke_dos_db_handler_lambda(lambda_payload) + data = loads(response) + data = literal_eval(data) + if data != []: + break + sleep(30) + else: + msg = "Error!.. Service Id not found" + raise ValueError(msg) + return data[0]["id"] + + +def get_change_event_standard_opening_times(service_id: str) -> Any: + """Get change event standard opening times. + + Args: + service_id (str): Service id. + + Returns: + Any: Change event standard opening times. + """ + lambda_payload = {"type": "change_event_standard_opening_times", "service_id": service_id} + response = invoke_dos_db_handler_lambda(lambda_payload) + return loads(response) + + +def get_change_event_specified_opening_times(service_id: str) -> Any: + """Get change event specified opening times. + + Args: + service_id (str): Service id. + + Returns: + Any: Change event specified opening times. + """ + lambda_payload = {"type": "change_event_specified_opening_times", "service_id": service_id} + response = invoke_dos_db_handler_lambda(lambda_payload) + return loads(response) + + +def get_service_table_field(service_id: str, field_name: str) -> Any: + """Get service table field. + + Args: + service_id (str): Service id. + field_name (str): Field name. + + Returns: + Any: Service table field. + """ + query = f"SELECT {field_name} FROM services WHERE id = %(SERVICE_ID)s" # noqa: S608 + query_vars = {"SERVICE_ID": service_id} + lambda_payload = {"type": "read", "query": query, "query_vars": query_vars} + response = invoke_dos_db_handler_lambda(lambda_payload) + data = loads(loads(response)) + return data[0][field_name] + + +def get_palliative_care(service_id: str, wait_for_update: bool = True) -> bool: + """Get palliative care from DoS. + + Args: + service_id (str): Service ID + wait_for_update (bool): Wait for service to update + + Returns: + bool: True if palliative care is found, False otherwise + """ + if wait_for_update: + wait_for_service_update(service_id) + return get_service_sgsd(service_id, 360, 14167) + + +def get_blood_pressure_sgsd(service_id: str) -> bool: + """Get blood pressure sgsd from DoS. + + Args: + service_id (str): Service ID + + Returns: + bool: True if blood pressure sgsd is found, False otherwise + """ + wait_for_service_update(service_id) + return get_service_sgsd(service_id, 360, 14207) + + +def get_contraception_sgsd(service_id: str) -> bool: + """Get blood pressure sgsd from DoS. + + Args: + service_id (str): Service ID + + Returns: + bool: True if blood pressure sgsd is found, False otherwise + """ + wait_for_service_update(service_id) + return get_service_sgsd(service_id, 360, 14210) + + +def get_service_history(service_id: str) -> list[dict[str, Any]]: + """Gets the service history from the database. + + Args: + service_id (str): The service id to get the history for. + + Returns: + list[dict[str, Any]]: The service history. + """ + data = [] + retry_counter = 0 + query = "SELECT history FROM servicehistories WHERE serviceid = %(SERVICE_ID)s" + max_retry = 2 + while not data and retry_counter < max_retry: + query_vars = {"SERVICE_ID": service_id} + lambda_payload = {"type": "read", "query": query, "query_vars": query_vars} + response = invoke_dos_db_handler_lambda(lambda_payload) + data = loads(loads(response)) + retry_counter += 1 + sleep(30) + return loads(data[0]["history"]) if data != [] else data + + +def get_service_history_specified_opening_times(service_id: str) -> dict: + """This function grabs the latest cmsopentimespecified object for a service id and returns it. + + Args: + service_id (str): Service id to get service history for + + Returns: + specified_open_times (dict): Specified opening times from service history + """ + service_history = get_service_history(service_id) + return service_history[next(iter(service_history.keys()))]["new"]["cmsopentimespecified"] + + +def get_service_history_standard_opening_times(service_id: str) -> list: + """This function grabs the latest standard opening times changes from service history. + + Args: + service_id (str): Service id to get service history for + + Returns: + standard_opening_times_from_service_history (list): List of standard opening times from service history + """ + service_history = get_service_history(service_id) + return [ + {entry: service_history[next(iter(service_history.keys()))]["new"][entry]} + for entry in service_history[next(iter(service_history.keys()))]["new"] + if entry.endswith("day") + ] + + +def get_service_sgsd(service_id: str, sgid: int, sdid: int) -> bool: + """Get service sgsd from DoS. + + Args: + service_id (str): Service ID + sgid (int): Service Group ID + sdid (int): Service Definition ID + + Returns: + bool: True if service sgsd is found, False otherwise + """ + query = """SELECT sgsds.id as z_code from servicesgsds sgsds + WHERE sgsds.serviceid = %(SERVICE_ID)s + AND sgsds.sgid = %(SG_ID)s + AND sgsds.sdid = %(SD_ID)s + """ + lambda_payload = { + "type": "read", + "query": query, + "query_vars": {"SERVICE_ID": service_id, "SG_ID": sgid, "SD_ID": sdid}, + } + response = invoke_dos_db_handler_lambda(lambda_payload) + response = loads(loads(response)) + return len(response) > 0 diff --git a/test/integration/steps/functions/dos/set_up_data.py b/test/integration/steps/functions/dos/set_up_data.py new file mode 100644 index 000000000..ffb23bb08 --- /dev/null +++ b/test/integration/steps/functions/dos/set_up_data.py @@ -0,0 +1,82 @@ +from integration.steps.functions.utils import invoke_dos_db_handler_lambda + + +def set_up_palliative_care_z_code_in_db() -> None: + """This function sets up the palliative care symptom discriminator. + + Setup in the symptomdisciminators table and in the symptomgroupsymptomdiscriminators table. + """ + invoke_dos_db_handler_lambda( + { + "type": "insert", + "query": "INSERT INTO pathwaysdos.symptomdiscriminators (id, description) VALUES (14167, 'Pharmacy Palliative Care Medication Stockholder') ON CONFLICT DO NOTHING RETURNING id", # noqa: E501 + "query_vars": None, + }, + ) + invoke_dos_db_handler_lambda( + { + "type": "insert", + "query": "INSERT INTO pathwaysdos.symptomgroupsymptomdiscriminators (id, symptomgroupid, symptomdiscriminatorid) VALUES (10000, 360, 14167) ON CONFLICT DO NOTHING RETURNING id", # noqa: E501 + "query_vars": None, + }, + ) + + +def set_up_blood_pressure_z_code_in_db() -> None: + """This function sets up the blood pressure symptom discriminator. + + Setup in the symptomdisciminators table and in the symptomgroupsymptomdiscriminators table. + """ + invoke_dos_db_handler_lambda( + { + "type": "insert", + "query": "INSERT INTO pathwaysdos.symptomdiscriminators (id, description) VALUES (14207, 'Blood Pressure') ON CONFLICT DO NOTHING RETURNING id", # noqa: E501 + "query_vars": None, + }, + ) + invoke_dos_db_handler_lambda( + { + "type": "insert", + "query": "INSERT INTO pathwaysdos.symptomgroupsymptomdiscriminators (id, symptomgroupid, symptomdiscriminatorid) VALUES (10001, 360, 14207) ON CONFLICT DO NOTHING RETURNING id", # noqa: E501 + "query_vars": None, + }, + ) + + +def set_up_contraception_z_code_in_db() -> None: + """This function sets up the blood pressure symptom discriminator. + + Setup in the symptomdisciminators table and in the symptomgroupsymptomdiscriminators table. + """ + invoke_dos_db_handler_lambda( + { + "type": "insert", + "query": "INSERT INTO pathwaysdos.symptomdiscriminators (id, description) VALUES (14210, 'Contraception') ON CONFLICT DO NOTHING RETURNING id", # noqa: E501 + "query_vars": None, + }, + ) + invoke_dos_db_handler_lambda( + { + "type": "insert", + "query": "INSERT INTO pathwaysdos.symptomgroupsymptomdiscriminators (id, symptomgroupid, symptomdiscriminatorid) VALUES (10002, 360, 14210) ON CONFLICT DO NOTHING RETURNING id", # noqa: E501 + "query_vars": None, + }, + ) + + +def set_up_common_condition_service_types() -> None: + """This function sets up the common condition service types.""" + invoke_dos_db_handler_lambda( + { + "type": "insert", + "query": """INSERT INTO pathwaysdos.servicetypes (id, "name", nationalranking, searchcapacitystatus, capacitymodel, capacityreset) VALUES(148, 'NHS Community Blood Pressure Check service', '1', true, NULL, 'interval') ON CONFLICT DO NOTHING RETURNING id""", # noqa: E501 + "query_vars": None, + }, + ) + invoke_dos_db_handler_lambda( + { + "type": "insert", + "query": """INSERT INTO pathwaysdos.servicetypes (id, "name", nationalranking, searchcapacitystatus, capacitymodel, capacityreset) VALUES(149, 'NHS Community Pharmacy Contraception service', '1', true, NULL, 'interval') ON CONFLICT DO NOTHING RETURNING id""", # noqa: E501 + "query_vars": None, + }, + ) diff --git a/test/integration/steps/functions/dos/translation.py b/test/integration/steps/functions/dos/translation.py new file mode 100644 index 000000000..297f23893 --- /dev/null +++ b/test/integration/steps/functions/dos/translation.py @@ -0,0 +1,80 @@ +from integration.steps.functions.constants import ( + DOS_ADDRESS_FIELD_NAME, + DOS_ADDRESS_SERVICE_HISTORY_KEY, + DOS_POSTCODE_FIELD_NAME, + DOS_POSTCODE_SERVICE_HISTORY_KEY, + DOS_PUBLIC_PHONE_FIELD_NAME, + DOS_PUBLIC_PHONE_SERVICE_HISTORY_KEY, + DOS_STATUS_ID_FIELD_NAME, + DOS_TOWN_EASTING_NAME, + DOS_TOWN_FIELD_NAME, + DOS_TOWN_LATITUDE_NAME, + DOS_TOWN_LONGITUDE_NAME, + DOS_TOWN_NORTHING_NAME, + DOS_WEB_FIELD_NAME, + DOS_WEB_SERVICE_HISTORY_KEY, + DOS_ZCODE_SERVICE_HISTORY_KEY, +) + + +def get_service_table_field_name(plain_english_field_name: str) -> str: # noqa: C901 + """Convert a plain English field name to the name of the field in the services table.""" + match plain_english_field_name.lower(): + case "phone_no" | "phone" | "public_phone" | "publicphone": + field_name = DOS_PUBLIC_PHONE_FIELD_NAME + case "website" | "web": + field_name = DOS_WEB_FIELD_NAME + case "address": + field_name = DOS_ADDRESS_FIELD_NAME + case "postcode": + field_name = DOS_POSTCODE_FIELD_NAME + case "easting": + field_name = DOS_TOWN_EASTING_NAME + case "northing": + field_name = DOS_TOWN_NORTHING_NAME + case "town": + field_name = DOS_TOWN_FIELD_NAME + case "latitude": + field_name = DOS_TOWN_LATITUDE_NAME + case "longitude": + field_name = DOS_TOWN_LONGITUDE_NAME + case "status": + field_name = DOS_STATUS_ID_FIELD_NAME + case _: + msg = f"Error!.. Input parameter '{plain_english_field_name}' not compatible" + raise ValueError(msg) + return field_name + + +def get_service_history_data_key(plain_english_field_name: str) -> str: + """Convert a plain English field name to the name of the field in the services table.""" + match plain_english_field_name.lower(): + case "phone_no" | "phone" | "public_phone" | "publicphone": + field_name = DOS_PUBLIC_PHONE_SERVICE_HISTORY_KEY + case "website" | "web": + field_name = DOS_WEB_SERVICE_HISTORY_KEY + case "address": + field_name = DOS_ADDRESS_SERVICE_HISTORY_KEY + case "postcode": + field_name = DOS_POSTCODE_SERVICE_HISTORY_KEY + case "zcode" | "z-code" | "z code": + field_name = DOS_ZCODE_SERVICE_HISTORY_KEY + case _: + msg = f"Error!.. Input parameter '{plain_english_field_name}' not compatible" + raise ValueError(msg) + return field_name + + +def get_status_id(status: str) -> int: + """Convert a plain English field name to the id of the status in the statuses table.""" + match status.lower(): + case "active": + status_id = 1 + case "closed": + status_id = 2 + case "commissioning": + status_id = 3 + case _: + msg = f"Error!.. Input parameter '{status}' not compatible" + raise ValueError(msg) + return status_id diff --git a/test/integration/steps/functions/generator.py b/test/integration/steps/functions/generator.py new file mode 100644 index 000000000..9e1e31608 --- /dev/null +++ b/test/integration/steps/functions/generator.py @@ -0,0 +1,663 @@ +from ast import literal_eval +from datetime import datetime +from json import loads +from random import randrange +from re import fullmatch +from typing import Any + +from pytz import timezone + +from .context import Context +from .utils import invoke_dos_db_handler_lambda + + +def commit_new_service_to_dos(context: Context) -> Context: + """Commit new services to DoS. + + Args: + context (Context): Test context object. + + Returns: + Context: Test context object. + """ + qv = context.generator_data + query_vars = ( + f"{qv['uid']}", + f"{qv['name']}", + f"{qv['odscode']}", + "false", + f"{qv['address']}${qv['town']}", + f"{qv['town']}", + f"{qv['postcode']}", + f"{qv['publicphone']}", + f"{qv['web']}", + "2022-09-06 11:00:00.000 +0100", + "2022-09-06 11:00:00.000 +0100", + f"{qv['service_type']}", + f"{qv['service_status']}", + ) + query = """INSERT INTO pathwaysdos.services (uid, "name", odscode, openallhours, address, town, postcode, + publicphone, web, createdtime, modifiedtime, typeid, statusid) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""" + + lambda_payload = {"type": "read", "query": query, "query_vars": query_vars} + response = invoke_dos_db_handler_lambda(lambda_payload) + data = loads(response) + data = literal_eval(data) + context.generator_data["id"] = data[0]["id"] + context.service_id = data[0]["id"] + return context + + +# Generic Opening days and times to DOS +def add_single_opening_day(context: Context) -> None: + """Add a single opening day to a service in DoS. + + Args: + context (Context): Test context + """ + # This is a generic single Monday 9-5 opening time + service_id = context.service_id + query = f"INSERT INTO servicedayopenings(serviceid, dayid) VALUES({service_id},1) RETURNING id" + lambda_payload = {"type": "read", "query": query, "query_vars": None} + response = loads(invoke_dos_db_handler_lambda(lambda_payload)) + time_id = literal_eval(response)[0]["id"] + add_single_opening_time(context, time_id) + if "standard_openings" not in context.generator_data: + context.generator_data["standard_openings"] = [] + context.generator_data["standard_openings"].append( + {"day": "Monday", "open": True, "opening_time": "09:00", "closing_time": "17:00"}, + ) + + +def add_single_opening_time(context: Context, time_id: int) -> None: + """Add a single opening time to a service in DoS. + + Args: + context (Context): Test context + time_id (int): The id of the opening day + """ + query = ( + "INSERT INTO servicedayopeningtimes(starttime, endtime, servicedayopeningid) " + f"VALUES('09:00:00', '17:00:00', {time_id}) RETURNING id" + ) + lambda_payload = {"type": "read", "query": query, "query_vars": None} + invoke_dos_db_handler_lambda(lambda_payload) + context.change_event["OpeningTimes"].append( + { + "AdditionalOpeningDate": "", + "ClosingTime": "17:00", + "IsOpen": True, + "OffsetClosingTime": 780, + "OffsetOpeningTime": 540, + "OpeningTime": "09:00", + "OpeningTimeType": "General", + "Weekday": "Monday", + }, + ) + + +def add_single_specified_day(context: Context) -> None: + """Add a single specified opening day to a service in DoS. + + Args: + context (Context): Test context + """ + # This is a generic single specified opening day + service_id = context.service_id + query = ( + 'INSERT INTO servicespecifiedopeningdates("date", serviceid) ' + f"VALUES('2025-01-02', {service_id}) RETURNING id" + ) + lambda_payload = {"type": "read", "query": query, "query_vars": None} + response = loads(invoke_dos_db_handler_lambda(lambda_payload)) + time_id = literal_eval(response)[0]["id"] + add_single_specified_time(context, time_id) + if "specified_openings" not in context.generator_data: + context.generator_data["specified_openings"] = [] + context.generator_data["specified_openings"].append( + {"date": "Jan 02 2025", "open": True, "opening_time": "09:00", "closing_time": "17:00"}, + ) + + +def add_single_specified_time(context: Context, time_id: str) -> None: + """Add a single specified opening time to a service in DoS. + + Args: + context (Context): Test context + time_id (str): The id of the specified opening date + """ + query = ( + "INSERT INTO servicespecifiedopeningtimes" + "(starttime, endtime, isclosed, servicespecifiedopeningdateid)" + f"VALUES('09:00:00', '17:00:00', false, {time_id}) RETURNING id" + ) + lambda_payload = {"type": "read", "query": query, "query_vars": None} + invoke_dos_db_handler_lambda(lambda_payload) + context.change_event["OpeningTimes"].append( + { + "AdditionalOpeningDate": "Jan 02 2025", + "ClosingTime": "17:00", + "IsOpen": False, + "OffsetClosingTime": 780, + "OffsetOpeningTime": 540, + "OpeningTime": "09:00", + "OpeningTimeType": "Additional", + "Weekday": "", + }, + ) + + +# Standard opening days with specified times to DOS +def add_standard_openings_to_dos(context: Context) -> None: + """Add standard opening days to DoS Service in database. + + Args: + context (dict): Test context + """ + for day in context.generator_data["standard_openings"]: + query = ( + "INSERT INTO pathwaysdos.servicedayopenings(serviceid, dayid) VALUES " # noqa: S608 + f'({int(context.service_id)}, {day_lookup(day["day"])}) RETURNING id' + ) + lambda_payload = {"type": "read", "query": query, "query_vars": None} + response = invoke_dos_db_handler_lambda(lambda_payload) + entry_id = literal_eval(loads(response))[0]["id"] + day["dos_id"] = entry_id + for day in context.generator_data["standard_openings"]: + if day["open"] is True: + opening_time = day["opening_time"] + closing_time = day["closing_time"] + day_id = day["dos_id"] + query = ( + "INSERT INTO pathwaysdos.servicedayopeningtimes(starttime, endtime, servicedayopeningid) VALUES " # noqa: S608 + f"('{opening_time}', " + f"'{closing_time}', " + f"{int(day_id)}) RETURNING id" + ) + lambda_payload = {"type": "read", "query": query, "query_vars": None} + invoke_dos_db_handler_lambda(lambda_payload) + + +# Specified opening days with specified times to DOS +def add_specified_openings_to_dos(context: Context) -> Any: + """Add specified opening days to DoS Service in database. + + Args: + context (dict): Test context + + Returns: + Any: Response from database + """ + for day in context.generator_data["specified_openings"]: + date = datetime.strptime(day["date"], "%b %d %Y").strftime("%Y-%m-%d") + query = ( + 'INSERT INTO pathwaysdos.servicespecifiedopeningdates("date", serviceid) ' + f"VALUES('{date!s}', {int(context.service_id)}) RETURNING id" + ) + lambda_payload = {"type": "read", "query": query, "query_vars": None} + response = invoke_dos_db_handler_lambda(lambda_payload) + entry_id = literal_eval(loads(response))[0]["id"] + day["dos_id"] = entry_id + for day in context.generator_data["specified_openings"]: + opening_time = day["opening_time"] + closing_time = day["closing_time"] + day_id = day["dos_id"] + closed_status = "" + if day["open"] is True: + closed_status = "false" + else: + closed_status = "true" + opening_time = "00:00:00" + closing_time = "00:00:00" + query = ( + "INSERT INTO pathwaysdos.servicespecifiedopeningtimes" + "(starttime, endtime, isclosed, servicespecifiedopeningdateid) VALUES(" + f"'{opening_time}', '{closing_time}', {closed_status}, {int(day_id)}) RETURNING id" + ) + if "'', '', false" in query: + msg = "Query has inserted null times into open specified date" + raise ValueError(msg) + lambda_payload = {"type": "read", "query": query, "query_vars": None} + invoke_dos_db_handler_lambda(lambda_payload) + # TO DO + return context + + +# Build change event for test +def build_change_event(context: Context) -> None: + """Build default change event for test. + + Args: + context (Context): Test context + """ + change_event = { + "ODSCode": context.generator_data["odscode"], + "Address1": context.generator_data["address"], + "Address2": None, + "Address3": None, + "City": context.generator_data["town"], + "Postcode": context.generator_data["postcode"], + "Contacts": build_change_event_contacts(context), + "County": None, + "OpeningTimes": build_change_event_opening_times(context), + "OrganisationName": context.generator_data["name"], + "OrganisationStatus": "Visible", + "OrganisationSubType": "Community", + "OrganisationType": "Pharmacy", + "OrganisationTypeId": "PHA", + "UniqueKey": generate_unique_key(), + "UecServices": [], + "Services": [], + "ParentOrganisation": {"ODSCode": "TES", "OrganisationName": "Fake Pharmacy Corporation"}, + "Staff": [], + } + context.change_event = change_event + + +def generate_staff() -> list: + """Generate staff for change event. + + Returns: + list: List of staff + """ + return [ + { + "Title": "Mr", + "GivenName": "Dave", + "FamilyName": "Davies", + "Role": "Superintendent Pharmacist", + "Qualification": "Pharmacist", + }, + {"Title": "Mr", "GivenName": "Tim", "FamilyName": "Timothy", "Role": "Locum Pharmacist", "Qualification": ""}, + ] + + +def build_change_event_contacts(context: Context) -> list: + """Build contacts for change event. + + Args: + context (Context): Test context + + Returns: + list: List of contacts + """ + contacts = [] + if context.generator_data["publicphone"] is not None: + contacts.append( + { + "ContactType": "Primary", + "ContactAvailabilityType": "Office hours", + "ContactMethodType": "Telephone", + "ContactValue": context.generator_data["publicphone"], + }, + ) + if context.generator_data["web"] is not None: + contacts.append( + { + "ContactType": "Primary", + "ContactAvailabilityType": "Office hours", + "ContactMethodType": "Website", + "ContactValue": context.generator_data["web"], + }, + ) + return contacts + + +def build_change_event_services(context: Context) -> list: + """Build services for change event. + + Args: + context (Context): Test context + + Returns: + list: List of change event services + """ + services = [] + if context.generator_data["blood pressure"] is not None: + services.append( + { + "ServiceName": "NHS Community Blood Pressure Check service", + "ServiceDescription": None, + "ServiceCode": "SRV0560", + }, + ) + if context.generator_data["contraception"] is not None: + services.append( + { + "ServiceName": "NHS Community Pharmacy Contraception service", + "ServiceDescription": None, + "ServiceCode": "SRV2000", + }, + ) + return services + + +def build_change_event_opening_times(context: Context) -> list: + """Build opening times for change event. + + Args: + context (Context): Test context + + Returns: + list: List of opening times (both standard and specified) + """ + opening_times = [] + if "standard_openings" in context.generator_data: + opening_times.extend( + { + "AdditionalOpeningDate": "", + "ClosingTime": days["closing_time"], + "IsOpen": days["open"], + "OffsetClosingTime": 780, + "OffsetOpeningTime": 540, + "OpeningTime": days["opening_time"], + "OpeningTimeType": "General", + "Weekday": days["day"], + } + for days in context.generator_data["standard_openings"] + ) + if "specified_openings" in context.generator_data: + present = datetime.now(timezone("Europe/London")) + opening_times.extend( + { + "AdditionalOpeningDate": days["date"], + "ClosingTime": days["closing_time"], + "IsOpen": days["open"], + "OffsetClosingTime": 780, + "OffsetOpeningTime": 540, + "OpeningTime": days["opening_time"], + "OpeningTimeType": "Additional", + "Weekday": "", + } + for days in context.generator_data["specified_openings"] + if datetime.strptime(days["date"], "%b %d %Y").date() > present.date() + ) + return opening_times + + +def return_opening_time_dict() -> dict: + """Returns a dictionary of change event opening times. + + Returns: + dict: Dictionary of opening times + """ + return { + "Weekday": "", + "OpeningTime": "", + "ClosingTime": "", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": "", + "IsOpen": True, + } + + +DAYS = {"monday": 1, "tuesday": 2, "wednesday": 3, "thursday": 4, "friday": 5, "saturday": 6, "sunday": 7} + + +# Other functions +def day_lookup(day: str) -> int: + """Returns the day of the week as an integer. + + Args: + day (str): Day of the week + + Returns: + int: Day of the week as an integer + """ + return DAYS[day.lower()] + + +def generate_unique_key(start_number: int = 1, stop_number: int = 1000) -> str: + """Generates a unique key for the change event. + + Args: + start_number (int, optional): Start number for randomiser. Defaults to 1. + stop_number (int, optional): Stop number for randomiser. Defaults to 1000. + + Returns: + str: Unique key + """ + return str(randrange(start=start_number, stop=stop_number, step=1)) + + +def query_standard_opening_builder( + context: Context, + service_status: str, + day: str, + open_time: str = "09:00", + close_time: str = "17:00", +) -> Context: + """Builds a query for standard opening times. + + Args: + context (Context): Test context + service_status (str): Open or closed + day (str): Day of the week + open_time (str, optional): Opening time for the standard opening. Defaults to "09:00". + close_time (str, optional): Closing time for the standard opening. Defaults to "17:00". + + Returns: + Context: Test context + """ + + def add_standard_opening_time(day: str, open_or_closed: bool, open_time: str, close_time: str) -> dict: + """Adds a standard opening time to the generator data. + + Args: + day (str): Day of the week + open_or_closed (bool): Open or closed + open_time (str): Opening time for the standard opening. + close_time (str): Closing time for the standard opening. + + Returns: + dict: Standard opening time + """ + return { + "day": day, + "open": open_or_closed, + "opening_time": open_time, + "closing_time": close_time, + } + + if service_status.lower() == "open": + times_obj = add_standard_opening_time(day, True, open_time, close_time) + else: + times_obj = add_standard_opening_time(day, False, "", "") + + if "standard_openings" not in context.generator_data: + context.generator_data["standard_openings"] = [] + else: + # Make sure that a closed statement removes opening statements + for days in context.generator_data["standard_openings"]: + if days["day"].lower() == day.lower() and times_obj["open"] != days["open"]: + context.generator_data["standard_openings"].remove(days) + context.generator_data["standard_openings"].append(times_obj) + return context + + +def query_specified_opening_builder( + context: Context, + service_status: str, + date: str, + open_time: str = "09:00", + close_time: str = "17:00", +) -> Context: + """Adds a specified opening to the generator_data. + + Args: + context (Context): Test context + service_status (str): Open or Closed + date (str): Date in format "Jan 01 2021" + open_time (str, optional): Opening time for the specified opening. Defaults to "09:00". + close_time (str, optional): Closing time for the specified opening. Defaults to "17:00". + + Returns: + Context: Test context + """ + + def add_specified_opening_time( + date: str, + open_or_closed: bool, + open_time: str, + close_time: str, + ) -> dict: + """Sets up the specified opening dictionary. + + Args: + date (str): Date in format "Jan 01 2021" + open_or_closed (bool): True for open, False for closed + open_time (str): Opening time for the specified opening. + close_time (str): Closing time for the specified opening. + + Returns: + dict: Specified Opening Time instance dictionary + """ + return { + "date": date, + "open": open_or_closed, + "opening_time": open_time, + "closing_time": close_time, + } + + if service_status.lower() == "open": + times_obj = add_specified_opening_time(date, True, open_time, close_time) + else: + times_obj = add_specified_opening_time(date, False, "", "") + if "specified_openings" not in context.generator_data: + context.generator_data["specified_openings"] = [] + else: + for entry in context.generator_data["specified_openings"]: + if entry["date"] == date: + context.generator_data["specified_openings"].remove(entry) + context.generator_data["specified_openings"].append(times_obj) + return context + + +def valid_change_event(context: Context) -> bool: + """This function checks if the data stored in DoS would pass the change request validation within DoS API Gateway. + + Args: + context (Context): The context object that contains the data to be validated. + + Returns: + bool: True if the data is valid, False if not. + """ + return bool( + ( + context.website is None + or fullmatch( + r"(https?:\/\/)?([a-z\d][a-z\d-]*[a-z\d]\.)+[a-z]{2,}(\/.*)?", + context.website, + ) + ) + and (context.phone is None or fullmatch(r"[+0][0-9 ()]{9,}", context.phone)), + ) + + +def apply_palliative_care_to_service(context: Context) -> int: + """This function creates an entry in DOS DB that will flag the service as having palliative care service. + + Args: + context (Context): The context object that contains the service ID to be flagged. + + Returns: + int: The ID of the entry in the database. + """ + srv = context.service_id + query = f"INSERT INTO pathwaysdos.servicesgsds (serviceid, sdid, sgid) VALUES ({srv}, 14167, 360) RETURNING id" # noqa: S608 + lambda_payload = {"type": "read", "query": query, "query_vars": None} + return loads(invoke_dos_db_handler_lambda(lambda_payload)) + + +def apply_blood_pressure_to_service(context: Context) -> int: + """This function creates an entry in DOS DB that will flag the service as having blood pressure service. + + Args: + context (Context): The context object that contains the service ID to be flagged. + + Returns: + int: The ID of the entry in the database. + """ + srv = context.service_id + query = f"INSERT INTO pathwaysdos.servicesgsds (serviceid, sdid, sgid) VALUES ({srv}, 14207, 360) RETURNING id" # noqa: S608 + lambda_payload = {"type": "read", "query": query, "query_vars": None} + return loads(invoke_dos_db_handler_lambda(lambda_payload)) + + +def apply_contraception_to_service(context: Context) -> int: + """This function creates an entry in DOS DB that will flag the service as having contraception service. + + Args: + context (Context): The context object that contains the service ID to be flagged. + + Returns: + int: The ID of the entry in the database. + """ + srv = context.service_id + query = f"INSERT INTO pathwaysdos.servicesgsds (serviceid, sdid, sgid) VALUES ({srv}, 14210, 360) RETURNING id" # noqa: S608 + lambda_payload = {"type": "read", "query": query, "query_vars": None} + return loads(invoke_dos_db_handler_lambda(lambda_payload)) + + +def add_palliative_care_to_change_event(context: Context) -> None: + """This function creates an entry in the Change Event containing a palliative care service. + + Args: + context (Context): The context object that contains the change event to be updated. + """ + if "UecServices" not in context.change_event: + context.change_event["UecServices"] = [] + context.change_event["UecServices"].append( + { + "ServiceName": "Pharmacy palliative care medication stockholder", + "ServiceDescription": None, + "ServiceCode": "SRV0559", + }, + ) + + +def remove_palliative_care_to_change_event(context: Context) -> None: + """This function removes an entry in the Change Event containing a palliative care service. + + Args: + context (Context): The context object that contains the change event to be updated. + """ + if "UecServices" in context.change_event: + context.change_event["UecServices"] = [] + + +def add_blood_pressure_to_change_event(context: Context) -> None: + """This function creates an entry in the Change Event containing a blood pressure service. + + Args: + context (Context): The context object that contains the change event to be updated. + """ + if "Services" not in context.change_event: + context.change_event["Services"] = [] + context.change_event["Services"].append( + { + "ServiceName": "NHS Community Blood Pressure Check service", + "ServiceDescription": None, + "ServiceCode": "SRV0560", + }, + ) + + +def add_contraception_to_change_event(context: Context) -> None: + """This function creates an entry in the Change Event containing a blood pressure service. + + Args: + context (Context): The context object that contains the change event to be updated. + """ + if "Services" not in context.change_event: + context.change_event["Services"] = [] + context.change_event["Services"].append( + { + "ServiceName": "NHS Community Pharmacy Contraception service", + "ServiceDescription": None, + "ServiceCode": "SRV2000", + }, + ) diff --git a/test/integration/steps/functions/slack.py b/test/integration/steps/functions/slack.py new file mode 100644 index 000000000..a44b09a8f --- /dev/null +++ b/test/integration/steps/functions/slack.py @@ -0,0 +1,61 @@ +from json import loads +from time import sleep, time + +from requests import get + +from .aws.secrets_manager import get_secret + + +def slack_retry(message: str) -> str: + """Retries slack message for 5 minutes. + + Args: + message (str): Message to check for + + Raises: + ValueError: If message is not found in slack + + Returns: + str: Response from slack + """ + slack_channel, slack_oauth = slack_secrets() + for _ in range(6): + sleep(60) + response_value = check_slack(slack_channel, slack_oauth) + if message in response_value: + return response_value + msg = f"Slack alert message not found, message: {message}" + raise ValueError(msg) + + +def slack_secrets() -> tuple[str, str]: + """Gets the slack secrets from AWS secrets manager. + + Returns: + tuple[str, str]: Slack channel and slack oauth token + """ + slack_secrets = loads(get_secret("uec-dos-int-dev/deployment")) + return slack_secrets["SLACK_CHANNEL"], slack_secrets["SLACK_OAUTH"] + + +def check_slack(channel: str, token: str) -> str: + """Gets slack messages for the specified channel. + + Args: + channel (str): Slack channel to get messages for + token (str): Slack token to use for authentication + + Returns: + str: Response text from the slack API + """ + headers = { + "Authorization": token, + "Content-Type": "application/json", + } + current = str(time() - 3600) + output = get( + url=f"https://slack.com/api/conversations.history?channel={channel}&oldest={current}", + headers=headers, + timeout=10, + ) + return output.text diff --git a/test/integration/steps/functions/utils.py b/test/integration/steps/functions/utils.py new file mode 100644 index 000000000..372b93671 --- /dev/null +++ b/test/integration/steps/functions/utils.py @@ -0,0 +1,284 @@ +import secrets +import string +from json import dumps, loads +from os import getenv +from random import randint, randrange +from re import sub +from time import time, time_ns +from typing import Any + +from .aws.aws_lambda import invoke_dos_db_handler_lambda +from .aws.cloudwatch import get_logs, negative_log_check +from .aws.dynamodb import get_latest_sequence_id_for_a_given_odscode +from .context import Context + + +def generate_unique_sequence_number(odscode: str) -> str: + """Generate unique sequence number. + + Args: + odscode (str): ODSCode. + + Returns: + str: Unique sequence number. + """ + return str(get_latest_sequence_id_for_a_given_odscode(odscode) + 1) + + +def generate_random_int(start_number: int = 1, stop_number: int = 1000) -> str: + """Generate random int. + + Args: + start_number (int, optional): Start number. Defaults to 1. + stop_number (int, optional): Stop number. Defaults to 1000. + + Returns: + str: Random int. + """ + return str(randrange(start=start_number, stop=stop_number, step=1)) + + +def create_pending_change_for_service(service_id: str) -> None: + """Create pending change for service. + + Args: + service_id (str): Service id. + """ + unique_id = randint(10000, 99999) + json_obj = { + "new": { + "cmstelephoneno": {"changetype": "add", "data": "abcd", "area": "demographic", "previous": "0"}, + "cmsurl": {"changetype": "add", "data": "abcd", "area": "demographic", "previous": ""}, + }, + "initiator": {"userid": "admin", "timestamp": "2022-09-01 13:35:41"}, + "approver": {"userid": "admin", "timestamp": "01-09-2022 13:35:41"}, + } + query = ( + "INSERT INTO pathwaysdos.changes " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id" + ) + query_vars = ( + f"66301ABC-D3A4-0B8F-D7F8-F286INT{unique_id}", + "PENDING", + "modify", + "admin", + "Test Duplicate", + "DoS Region", + dumps(json_obj), + "2022-09-06 11:00:00.000 +0100", + "admin", + "2022-09-06 11:00:00.000 +0100", + "admin", + service_id, + None, + None, + None, + ) + lambda_payload = {"type": "write", "query": query, "query_vars": query_vars} + invoke_dos_db_handler_lambda(lambda_payload) + + +def get_expected_data(context: Context, changed_data_name: str) -> Any: + """Get the previous data from the context.""" + match changed_data_name.lower(): + case "phone_no" | "phone" | "public_phone" | "publicphone": + changed_data = context.generator_data["publicphone"] + case "website" | "web": + changed_data = context.generator_data["web"] + case "address" | "address1": + changed_data = get_address_string(context) + case "postcode": + changed_data = context.change_event["Postcode"] + case _: + msg = f"Error!.. Input parameter '{changed_data_name}' not compatible" + raise ValueError(msg) + return changed_data + + +def get_address_string(context: Context) -> str: + """Get the address string from the context. + + Args: + context (Context): Test context + + Returns: + str: Address string + """ + address_lines = [ + line + for line in [ + context.change_event["Address1"], + context.change_event["Address2"], + context.change_event["Address3"], + context.change_event["City"], + context.change_event["County"], + ] + if isinstance(line, str) and line.strip() + ] + address = "$".join(address_lines) + address = sub(r"[A-Za-z]+('[A-Za-z]+)?", lambda word: word.group(0).capitalize(), address) + address = address.replace("'", "") + return address.replace("&", "and") + + +def convert_specified_opening(specified_date: dict, closed_status: bool = False) -> str: + """Converts opening times from CE format to DOS format. + + Args: + specified_date (dict): Specified opening dates from change event + closed_status (bool): Closed Status since output string changes if closed + + Returns: + return_string (str): Converted opening dates/times in dos string format "dd-mm-yyyy-06000-12000" + """ + months = { + "Jan": "01", + "Feb": "02", + "Mar": "03", + "Apr": "04", + "May": "05", + "Jun": "06", + "Jul": "07", + "Aug": "08", + "Sep": "09", + "Oct": "10", + "Nov": "11", + "Dec": "12", + } + split_date = specified_date["AdditionalOpeningDate"].split(" ") + selected_month = months[split_date[0]] + if closed_status: + return f"{split_date[2]}-{selected_month}-{split_date[1]}-closed" + opening_time = time_to_seconds(specified_date["OpeningTime"]) + closing_time = time_to_seconds(specified_date["ClosingTime"]) + return f"{split_date[2]}-{selected_month}-{split_date[1]}-{opening_time}-{closing_time}" + + +def convert_standard_opening(standard_times: list[dict]) -> list[dict]: + """Converts standard opening times from change event to be comparable with service history. + + Args: + standard_times (list[dict]): Standard Opening times pulled from Change Event + + Returns: + return_list (list[dict]): List of Dicts containing name of the day in cms format and times in seconds. + """ + return_list = [] + for entry in standard_times: + current_day = entry["Weekday"].lower() + if entry["IsOpen"] is True: + opening_time = time_to_seconds(entry["OpeningTime"]) + closing_time = time_to_seconds(entry["ClosingTime"]) + return_list.append({"name": f"cmsopentime{current_day}", "times": f"{opening_time}-{closing_time}"}) + else: + return_list.append({"name": f"cmsopentime{current_day}", "times": "closed"}) + return return_list + + +def time_to_seconds(time: str) -> str: + """Converts time to seconds. + + Args: + time (str): The time to convert. + + Returns: + str: The time in seconds. + """ + times = time.split(":") + hour_seconds = int(times[0]) * 3600 + minutes_seconds = int(times[1]) * 60 + return str(hour_seconds + minutes_seconds) + + +def check_recent_event(event_time: str, time_difference: int = 600) -> bool: + """Checks if the event time is within the time difference. + + Args: + event_time (str): The event time to check. + time_difference (int, optional): Time difference in seconds. Defaults to 600. + + Returns: + bool: True if the event time is within the time difference. + """ + return int(time() - int(event_time)) <= time_difference + + +def generate_correlation_id(suffix: None | str = None) -> str: + """Generates a correlation id for the lambda to use. + + Args: + suffix (None | str): Suffix for correlation id. Defaults to None. + + Returns: + str: Correlation id + """ + name_no_space = getenv("PYTEST_CURRENT_TEST").split(":")[-1].split(" ")[0].replace(" ", "_") + run_id = getenv("RUN_ID") + correlation_id = f"{run_id}_{time_ns()}_{name_no_space}" if suffix is None else f"{run_id}_{time_ns()}_{suffix}" + correlation_id = ( + correlation_id if len(correlation_id) < 80 else correlation_id[:79] + ) # DoS API Gateway max reference is 100 characters + return correlation_id.replace("'", "") + + +def quality_checker_log_check( + request_id: str, + odscode: str, + reason: str, + start_time: str, + match_on_more_than_5_character_odscode: bool = False, +) -> list[dict]: + """Check logs for quality checker. + + Args: + request_id (str): Quality checker function request id + odscode (str): ODS code + reason (str): Reason for quality check + start_time (str): Start time for the query + match_on_more_than_5_character_odscode (bool): Match on more than 5 character odscode. Defaults to False. + + Returns: + list[dict]: logs + """ + query = f"""fields message +| filter function_request_id="{request_id}" +| filter report_key="QUALITY_CHECK_REPORT_KEY" +| filter {'dos_service_odscode' if match_on_more_than_5_character_odscode else 'odscode'}="{odscode}" +| filter message="{reason}" +| sort @timestamp asc""" + results = loads(get_logs(query=query, lambda_name="quality-checker", start_time=start_time)) + return results["results"] + + +def quality_checker_negative_log_check( + request_id: str, + odscode: str, + reason: str, + start_time: str, + match_on_more_than_5_character_odscode: bool = False, +) -> bool: + """Check no logs for quality checker. + + Args: + request_id (str): Quality checker function request id + odscode (str): ODS code + reason (str): Reason for quality check + start_time (str): Start time for the query + match_on_more_than_5_character_odscode (bool): Match on more than 5 character odscode. Defaults to False. + + Returns: + bool: True if no logs found + """ + query = f"""fields message +| filter function_request_id="{request_id}" +| filter report_key="QUALITY_CHECK_REPORT_KEY" +| filter {'dos_service_odscode' if match_on_more_than_5_character_odscode else 'odscode'}="{odscode}" +| filter message="{reason}" +| sort @timestamp asc""" + return negative_log_check(query=query, event_lambda="quality-checker", start_time=start_time) + + +def generate_unique_ods_code() -> str: + """Generate a unique 5-character uppercase alphanumeric ODSCode.""" + chars = string.ascii_uppercase + string.digits # A-Z, 0-9 + return "".join(secrets.choice(chars) for _ in range(5)) # 5-character random string diff --git a/test/integration/steps/pytest.ini b/test/integration/steps/pytest.ini index 84ebdf35f..cebd6688b 100644 --- a/test/integration/steps/pytest.ini +++ b/test/integration/steps/pytest.ini @@ -1,12 +1,16 @@ [pytest] markers = - dev: Test that can be run on both task and dev environments. complete: The full test suite. - smoke: Tests that can be run in Prod Environments. - event_sender: Tests that test the event sender - security: Tests that test the security features wip: Tests that are not yet complete - cloudwatch_queries: Complete tag tests that include cloudwatch queries - no_log_searches: Complete tag tests that do not include cloudwatch queries + broken: Tests to be fixed + +# The following are used to run specific groups of tests, mainly for pipeline testing. + general: Tests that do not fall into other groups + validation: Tests that validate incorrect data is not processed + slack_and_infrastructure: Tests that validate slack alerts and infrastructure + reporting: Tests that validate reporting + opening_times: Tests that check/update opening times + quality_checker: Tests that test the quality checker functionality + filterwarnings = ignore::DeprecationWarning diff --git a/test/integration/steps/test_parent_steps.py b/test/integration/steps/test_parent_steps.py deleted file mode 100644 index 7767d067c..000000000 --- a/test/integration/steps/test_parent_steps.py +++ /dev/null @@ -1,981 +0,0 @@ -from datetime import datetime as dt -from decimal import Decimal -from time import sleep, time -from os import getenv -from random import randint -from faker import Faker -import datetime -from json import loads -import ast - -from pytest_bdd import given, parsers, scenarios, then, when - -from .utilities.events import ( - build_same_as_dos_change_event, - build_same_as_dos_change_event_by_ods, - change_request, - create_change_event, - set_opening_times_change_event, - valid_change_event, -) -from .utilities.constants import DENTIST_ORG_TYPE_ID, ORGANISATION_SUB_TYPES_KEY -from .utilities.aws import get_logs, negative_log_check -from .utilities.utils import ( - generate_correlation_id, - get_changes, - get_service_id, - get_change_event_standard_opening_times, - get_change_event_specified_opening_times, - confirm_approver_status, - get_stored_events_from_dynamo_db, - process_change_request_payload, - process_payload, - check_contact_delete_in_dos, - process_payload_with_sequence, - re_process_payload, - get_latest_sequence_id_for_a_given_odscode, - check_received_data_in_dos, - check_standard_received_opening_times_time_in_dos, - check_specified_received_opening_times_time_in_dos, - check_specified_received_opening_times_date_in_dos, - time_to_sec, - confirm_changes, - get_service_type_data, - get_service_type_from_cr, -) - -scenarios( - "../features/F001_Valid_Change_Events.feature", - "../features/F002_Invalid_Change_Events.feature", - "../features/F003_DoS_Security.feature", - "../features/F004_Error_Handling.feature", - "../features/F005_Support_Functions.feature", - "../features/F006_Opening_times.feature", -) -FAKER = Faker("en_GB") - - -@given("a Changed Event is valid", target_fixture="context") -def a_change_event_is_valid(): - context = {} - context["change_event"] = create_change_event("pharmacy") - return context - - -@given(parsers.parse('a "{org_type}" Changed Event is valid'), target_fixture="context") -def an_org_type_change_event(org_type): - context = {} - context["change_event"] = create_change_event(org_type.lower()) - if org_type.lower() == "dentist": - context["change_event"]["OrganisationName"] = "Test Dentist" - context["change_event"]["OrganisationTypeId"] = DENTIST_ORG_TYPE_ID - context["change_event"]["OrganisationType"] = "Dental practice" - context["change_event"]["OrganisationSubType"] = get_service_type_data(DENTIST_ORG_TYPE_ID)[ - ORGANISATION_SUB_TYPES_KEY - ][0] - context["change_event"]["Address1"] = FAKER.street_name() - return context - - -@given("a Dentist Changed Event is valid", target_fixture="context") -def valid_dentist_change_event(): - context = {} - context["change_event"] = create_change_event("dentist") - context["change_event"]["OrganisationName"] = "Test Dentist" - context["change_event"]["OrganisationTypeId"] = DENTIST_ORG_TYPE_ID - context["change_event"]["OrganisationType"] = "Dental practice" - context["change_event"]["OrganisationSubType"] = get_service_type_data(DENTIST_ORG_TYPE_ID)[ - ORGANISATION_SUB_TYPES_KEY - ][0] - context["change_event"]["Address1"] = FAKER.street_name() - return context - - -@given(parsers.parse('a Changed Event with changed "{contact}" is valid'), target_fixture="context") -def a_changed_contact_event_is_valid(contact): - context = {} - context["change_event"] = create_change_event("pharmacy") - validated = False - while validated is False: - if contact == "website": - context["change_event"]["Contacts"][0]["ContactValue"] = FAKER.domain_word() + ".nhs.uk" - elif contact == "phone_no": - context["change_event"]["Contacts"][1]["ContactValue"] = FAKER.phone_number() - elif contact == "address": - context["change_event"]["Address1"] = FAKER.street_name() - else: - raise ValueError(f"ERROR!.. Input parameter '{contact}' not compatible") - - validated = valid_change_event(context["change_event"]) - return context - - -@given(parsers.parse('a Changed Event with value "{data}" for "{contact_field}"'), target_fixture="context") -def a_valid_changed_event_with_empty_contact(data, contact_field): - def get_value_from_data(): - if data == "None": - return None - elif data == "''": - return "" - else: - return data - - context = {} - context["change_event"] = create_change_event("pharmacy") - context["change_event"]["ODSCode"] = "FAA96" - if contact_field == "website": - context["change_event"]["Contacts"][0]["ContactValue"] = get_value_from_data() - elif contact_field == "phone_no": - context["change_event"]["Contacts"][1]["ContactValue"] = get_value_from_data() - else: - raise ValueError(f"ERROR!.. Input parameter '{contact_field}' not compatible") - return context - - -@given("a specific Changed Event is valid", target_fixture="context") -def a_specific_change_event_is_valid(): - context = {} - context["change_event"] = set_opening_times_change_event("pharmacy") - return context - - -@given("an opened specified opening time Changed Event is valid", target_fixture="context") -def a_specified_opening_time_change_event_is_valid(): - closing_time = datetime.datetime.now().time().strftime("%H:%M") - context = {} - context["change_event"] = set_opening_times_change_event("pharmacy") - context["change_event"]["OpeningTimes"][-1]["OpeningTime"] = "00:01" - context["change_event"]["OpeningTimes"][-1]["ClosingTime"] = closing_time - context["change_event"]["OpeningTimes"][-1]["IsOpen"] = True - return context - - -@given("an opened standard opening time Changed Event is valid", target_fixture="context") -def a_standard_opening_time_change_event_is_valid(): - closing_time = datetime.datetime.now().time().strftime("%H:%M") - context = {} - context["change_event"] = set_opening_times_change_event("pharmacy") - context["change_event"]["OpeningTimes"][-2]["Weekday"] = "Monday" - context["change_event"]["OpeningTimes"][-2]["OpeningTime"] = "00:01" - context["change_event"]["OpeningTimes"][-2]["ClosingTime"] = closing_time - context["change_event"]["OpeningTimes"][-2]["IsOpen"] = True - return context - - -@given("a Changed Event is aligned with Dos", target_fixture="context") -def a_change_event_is_valid_and_matches_dos(): - context = {} - context["change_event"] = build_same_as_dos_change_event("pharmacy") - return context - - -@given(parsers.parse('a Changed Event to unset "{contact}"'), target_fixture="context") -def a_change_event_is_valid_with_contact_set(contact: str): - context = {} - - context["change_event"] = build_same_as_dos_change_event_by_ods("pharmacy", "FYH55") - if contact.lower() == "website": - del context["change_event"]["Contacts"][0] - elif contact.lower() == "phone": - del context["change_event"]["Contacts"][1] - else: - raise ValueError(f"Invalid contact '{contact}' provided") - return context - - -@given("a valid unsigned change request", target_fixture="context") -def a_change_request_is_valid(): - context = {} - context["change_request"] = change_request() - return context - - -@given("the Changed Event has overlapping opening times", target_fixture="context") -def change_event_with_overlapping_opening_times(context): - context["change_event"]["OpeningTimes"][0]["ClosingTime"] = "12:00" - context["change_event"]["OpeningTimes"][1]["Weekday"] = "Monday" - context["change_event"]["OpeningTimes"][1]["OpeningTime"] = "11:00" - return context - - -@given("the Changed Event has one break in opening times", target_fixture="context") -def change_event_with_break_in_opening_times(context): - context["change_event"]["OpeningTimes"][0]["ClosingTime"] = "11:00" - context["change_event"]["OpeningTimes"][1]["Weekday"] = "Monday" - context["change_event"]["OpeningTimes"][1]["OpeningTime"] = "12:00" - return context - - -@given("the Changed Event has two breaks in opening times", target_fixture="context") -def change_event_with_two_breaks_in_opening_times(context): - context["change_event"]["OpeningTimes"][0]["ClosingTime"] = "11:00" - context["change_event"]["OpeningTimes"][1]["Weekday"] = "Monday" - context["change_event"]["OpeningTimes"][1]["OpeningTime"] = "12:00" - context["change_event"]["OpeningTimes"][1]["ClosingTime"] = "14:00" - context["change_event"]["OpeningTimes"][2]["Weekday"] = "Monday" - context["change_event"]["OpeningTimes"][2]["OpeningTime"] = "16:00" - return context - - -@given("the website field contains special characters", target_fixture="context") -def change_event_with_special_address_characters(context): - uniqueval = int(time()) - context["change_event"]["Contacts"][0][ - "ContactValue" - ] = f"https:\/\/www.rowlandspharmacy.co.uk\/test?foo={uniqueval}" # noqa: W605 - context["uri_timestamp"] = uniqueval - return context - - -@given( - parsers.parse('the Changed Event contains a one off opening date thats "{open_closed}"'), target_fixture="context" -) -def one_off_opening_date_set(context, open_closed: str): - context["change_event"]["OpeningTimes"][0]["OpeningTimeType"] = "Additional" - selectedday = randint(10, 30) - context["change_event"]["OpeningTimes"][0]["AdditionalOpeningDate"] = f"Dec {selectedday} 2025" - context["change_event"]["OpeningTimes"][0]["Weekday"] = "" - if open_closed.lower() == "open": - context["change_event"]["OpeningTimes"][0]["OpeningTime"] = "09:00" - context["change_event"]["OpeningTimes"][0]["ClosingTime"] = "17:00" - context["change_event"]["OpeningTimes"][0]["IsOpen"] = True - elif open_closed.lower() == "closed": - context["change_event"]["OpeningTimes"][0]["OpeningTime"] = "" - context["change_event"]["OpeningTimes"][0]["ClosingTime"] = "" - context["change_event"]["OpeningTimes"][0]["IsOpen"] = False - return context - - -@given("the Changed Event closes the pharmacy on a bank holiday", target_fixture="context") -def bank_holiday_pharmacy_closed(context): - context["change_event"]["OpeningTimes"][0]["OpeningTimeType"] = "Additional" - nextyear = dt.now().year + 1 - context["change_event"]["OpeningTimes"][0]["AdditionalOpeningDate"] = f"Dec 25 {nextyear}" - context["change_event"]["OpeningTimes"][0]["Weekday"] = "" - context["change_event"]["OpeningTimes"][0]["OpeningTime"] = "" - context["change_event"]["OpeningTimes"][0]["ClosingTime"] = "" - context["change_event"]["OpeningTimes"][0]["IsOpen"] = False - return context - - -@given("a Changed Event with invalid ODSCode is provided", target_fixture="context") -def a_change_event_with_invalid_odscode(): - change_event = create_change_event("pharmacy") - change_event["ODSCode"] = "F8KE1" - context = {"change_event": change_event} - return context - - -@given(parsers.parse('the Changed Event has ODS Code "{ods_code}"'), target_fixture="context") -def a_change_event_with_custom_ods(context, ods_code: str): - context["change_event"]["ODSCode"] = ods_code - return context - - -@given("a Changed Event contains an incorrect OrganisationSubtype", target_fixture="context") -def a_change_event_with_invalid_organisationsubtype(): - context = {} - context["change_event"] = create_change_event("pharmacy") - context["change_event"]["OrganisationSubType"] = "com" - return context - - -@given("a Changed Event contains an incorrect OrganisationTypeID", target_fixture="context") -def a_change_event_with_invalid_organisationtypeid(): - context = {} - context["change_event"] = create_change_event("pharmacy") - context["change_event"]["OrganisationTypeId"] = "DEN" - return context - - -# # Weekday NOT present on the Opening Time -@given("a Changed Event with the Weekday NOT present in the Opening Times data", target_fixture="context") -def a_change_event_with_no_openingtimes_weekday(): - context = {} - context["change_event"] = create_change_event("pharmacy") - del context["change_event"]["OpeningTimes"][0]["Weekday"] - return context - - -# # OpeningTimeType is NOT "General" or "Additional" -@given("a Changed Event where OpeningTimeType is NOT defined correctly", target_fixture="context") -def a_change_event_with_invalid_openingtimetype(): - context = {} - context["change_event"] = create_change_event("pharmacy") - context["change_event"]["OpeningTimes"][0]["OpeningTimeType"] = "F8k3" - return context - - -# set correlation id to contain "Bad Request" -@given(parsers.parse('the correlation-id is "{custom_correlation}"'), target_fixture="context") -def a_custom_correlation_id_is_set(context, custom_correlation: str): - context["correlation_id"] = generate_correlation_id(custom_correlation) - return context - - -# # isOpen is false AND Times in NOT blank -@given("a Changed Event with the openingTimes IsOpen status set to false", target_fixture="context") -def a_change_event_with_isopen_status_set_to_false(): - context = {} - context["change_event"] = create_change_event("pharmacy") - context["change_event"]["OpeningTimes"][0]["IsOpen"] = False - return context - - -# # Check that the requested ODS code exists in ddb, and create an entry if not -@given("an ODS has an entry in dynamodb", target_fixture="context") -def current_ods_exists_in_ddb(): - context = {} - context["change_event"] = create_change_event("pharmacy") - odscode = context["change_event"]["ODSCode"] - if get_latest_sequence_id_for_a_given_odscode(odscode) == 0: - context = the_change_event_is_sent_with_custom_sequence(context, 100) - # New address prevents SQS dedupe - context["change_event"]["Address1"] = FAKER.street_name() - return context - - -# # IsOpen is true AND Times is blank -@when("the OpeningTimes Opening and Closing Times data are not defined", target_fixture="context") -def no_times_data_within_openingtimes(context): - context["change_event"] = create_change_event("pharmacy") - context["change_event"]["OpeningTimes"][0]["OpeningTime"] = "" - context["change_event"]["OpeningTimes"][0]["ClosingTime"] = "" - return context - - -# OpeningTimeType is Additional AND AdditionalOpening Date is Blank -@when( - "the OpeningTimes OpeningTimeType is Additional and AdditionalOpeningDate is not defined", - target_fixture="context", -) -def specified_opening_date_not_defined(context): - context["change_event"] = create_change_event("pharmacy") - context["change_event"]["OpeningTimes"][7]["AdditionalOpeningDate"] = "" - return context - - -# # An OpeningTime is received for the Day or Date where IsOpen is True and IsOpen is false. -@when("an AdditionalOpeningDate contains data with both true and false IsOpen status", target_fixture="context") -def same_specified_opening_date_with_true_and_false_isopen_status(context): - context["change_event"] = create_change_event("pharmacy") - context["change_event"]["OpeningTimes"][7]["AdditionalOpeningDate"] = "Dec 25 2022" - context["change_event"]["OpeningTimes"][7]["IsOpen"] = False - return context - - -@when( - parsers.parse('the Changed Event is sent for processing with "{valid_or_invalid}" api key'), - target_fixture="context", -) -def the_change_event_is_sent_for_processing(context, valid_or_invalid): - context["start_time"] = dt.today().timestamp() - if "correlation_id" not in context: - context["correlation_id"] = generate_correlation_id() - context["response"] = process_payload( - context["change_event"], valid_or_invalid == "valid", context["correlation_id"] - ) - context["sequence_no"] = context["response"].request.headers["sequence-number"] - print(f"Applied Correlation id: {context['correlation_id']}") - return context - - -# # Request with custom sequence id -@when( - parsers.parse('the Changed Event is sent for processing with sequence id "{seqid}"'), - target_fixture="context", -) -def the_change_event_is_sent_with_custom_sequence(context, seqid): - context["start_time"] = dt.today().timestamp() - context["correlation_id"] = generate_correlation_id() - context["response"] = process_payload_with_sequence(context["change_event"], context["correlation_id"], seqid) - context["sequence_no"] = seqid - return context - - -# # Request with no sequence id -@when( - parsers.parse("the Changed Event is sent for processing with no sequence id"), - target_fixture="context", -) -def the_change_event_is_sent_with_no_sequence(context): - context["start_time"] = dt.today().timestamp() - context["correlation_id"] = generate_correlation_id() - context["response"] = process_payload_with_sequence(context["change_event"], context["correlation_id"], None) - return context - - -# # Request with duplicate sequence id -@when( - parsers.parse("the Changed Event is sent for processing with a duplicate sequence id"), - target_fixture="context", -) -def the_change_event_is_sent_with_duplicate_sequence(context): - context["start_time"] = dt.today().timestamp() - context["correlation_id"] = generate_correlation_id() - odscode = context["change_event"]["ODSCode"] - seqid = get_latest_sequence_id_for_a_given_odscode(odscode) - context["response"] = process_payload_with_sequence(context["change_event"], context["correlation_id"], seqid) - context["sequence_no"] = seqid - return context - - -@when("the postcode has no LAT Long values", target_fixture="context") -def postcode_with_no_lat_long_values(context): - context["change_event"]["Postcode"] = "BT4 2HU" - return context - - -@when(parsers.parse('the OrganisationStatus is defined as "{org_status}"'), target_fixture="context") -def a_change_event_with_orgstatus_value(context, org_status: str): - context["change_event"]["OrganisationStatus"] = org_status - return context - - -@when("the postcode is invalid", target_fixture="context") -def postcode_is_invalid(context): - context["change_event"]["Postcode"] = "AAAA 123" - return context - - -@when(parsers.parse('the change request is sent with "{valid_or_invalid}" api key'), target_fixture="context") -def the_change_request_is_sent(context, valid_or_invalid): - context["start_time"] = datetime.today().timestamp() - context["response"] = process_change_request_payload(context["change_request"], valid_or_invalid == "valid") - return context - - -@then("no matched services were found") -def no_matched_services_found(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "Found 0 services in DB"' - ) - event_logs = get_logs(query, "processor", context["start_time"]) - assert event_logs != [], "ERROR!! No unmatched services log found.." - - -@then("the Changed Event is stored in dynamo db") -def stored_dynamo_db_events_are_pulled(context): - odscode = context["change_event"]["ODSCode"] - sequence_num = Decimal(context["sequence_no"]) - sleep(10) - db_event_record = get_stored_events_from_dynamo_db(odscode, sequence_num) - assert db_event_record is not None, f"ERROR!! Event record with odscode {odscode} NOT found!.." - assert ( - odscode == db_event_record["ODSCode"] - ), f"ERROR!!.. Change event record({odscode} - {db_event_record['ODSCode']}) mismatch!!" - assert sequence_num == db_event_record["SequenceNumber"], "ERROR!!.. Change event record(sequence no) mismatch!!" - return context - - -@then("the unmatched service exception is reported to cloudwatch", target_fixture="context") -def unmatched_service_exception(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "No matching DOS services"' - ) - logs = get_logs(query, "processor", context["start_time"]) - odscode = context["change_event"]["ODSCode"] - assert f"ODSCode '{odscode}'" in logs, "ERROR!!.. Expected unmatched service logs not found." - return context - - -@then("no Changed request is created", target_fixture="context") -def no_cr_created(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter message = "No changes identified"' - ) - logs = get_logs(query, "processor", context["start_time"]) - assert logs != [], "ERROR!!.. Unexpected Changed request found.." - return context - - -@then("the exception is reported to cloudwatch", target_fixture="context") -def service_exception(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter level="ERROR"' - ) - logs = get_logs(query, "processor", context["start_time"]) - assert logs != [], "ERROR!!.. Expected exception not logged." - return context - - -@then("the OpeningTimes exception is reported to cloudwatch") -def openingtimes_service_exception(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "Changes for nhs"' - ) - logs = get_logs(query, "processor", context["start_time"]) - assert "opening_dates" not in logs, "ERROR!!.. Expected OpeningTimes exception not captured." - - -@then("the invalid postcode exception is reported to cloudwatch") -def unmatched_postcode_exception(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter report_key like "INVALID_POSTCODE"' - ) - logs = get_logs(query, "processor", context["start_time"]) - postcode = context["change_event"]["Postcode"] - assert f"postcode '{postcode}'" in logs, "ERROR!!.. Expected Invalid Postcode exception not found." - - -@then("the unmatched service type exception is reported to cloudwatch") -def unmatched_service_type_exception(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter report_key like "UNMATCHED_SERVICE_TYPE"' - ) - logs = get_logs(query, "processor", context["start_time"]) - odscode = context["change_event"]["ODSCode"] - assert f"{odscode}" in logs, "ERROR!!.. Expected Unmatched Service Type exception not found." - - -@then("the generic bank holiday exception is reported to cloudwatch") -def generic_bank_holiday_exception(context): - query = ( - f'fields message, ods_code | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter report_key like "GENERIC_BANK_HOLIDAY"' - ) - logs = get_logs(query, "processor", context["start_time"]) - odscode = context["change_event"]["ODSCode"] - assert f"{odscode}" in logs, "ERROR!!.. Expected Generic Bank Holiday exception not found." - - -@then("the hidden or closed exception is reported to cloudwatch") -def hidden_or_closed_exception(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "NHS Service marked as closed or hidden"' - ) - logs = get_logs(query, "processor", context["start_time"]) - assert ( - "no change requests will be produced" in logs - ), "ERROR!!.. Expected hidden or closed exception logs not found." - - -@then(parsers.parse("the {address} from the changes is not included in the change request")) -def address_change_is_discarded_in_event_sender(context, address: str): - query = ( - f'fields change_request_body | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - '| filter message like "Attempting to send change request to DoS"' - ) - logs = get_logs(query, "sender", context["start_time"]) - assert f"{address}" not in logs, "ERROR!!.. Unexpected Address change found in logs." - - -@then("the processed Changed Request is sent to Dos", target_fixture="context") -def processed_changed_request_sent_to_dos(context): - cr_received_search_param = "Received change request" - cr_sent_search_param = "Successfully send change request to DoS" - cr_received_query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - f' | filter message like "{cr_received_search_param}"' - ) - cr_sent_query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - f' | filter message like "{cr_sent_search_param}"' - ) - cr_received_logs = get_logs(cr_received_query, "sender", context["start_time"]) - assert cr_received_logs != [], "ERROR!!.. Expected Sender logs not found." - cr_sent_logs = get_logs(cr_sent_query, "sender", context["start_time"]) - assert cr_sent_logs != [], "ERROR!!.. Expected sent event confirmation in service logs not found." - return context - - -@then("the Changed Event is not processed any further") -def the_changed_event_is_not_processed(context): - cr_received_search_param = "Received change request" - query = f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - logs = get_logs(query, "processor", context["start_time"]) - assert f"{cr_received_search_param}" not in logs, "ERROR!!.. expected exception logs not found." - - -@then("the Changed Request is accepted by Dos") -def the_changed_request_is_accepted_by_dos(context): - """assert dos API response and validate processed record in Dos CR Queue database""" - response = confirm_changes(context["correlation_id"]) - assert response != [], "ERROR!!.. Expected Event confirmation in Dos not found." - return context - - -@then(parsers.parse('the Changed Request is accepted by Dos with "{contact}" deleted')) -def the_changed_request_is_accepted_by_dos_with_contact_delete(context, contact): - service_id = get_service_id(context["correlation_id"]) - approver_status = confirm_approver_status(context["correlation_id"]) - if contact == "phone": - cms = "cmstelephoneno" - elif contact == "website": - cms = "cmsurl" - else: - raise ValueError(f"Invalid contact provided: '{contact}'") - assert approver_status != [], f"Error!.. Dos Change for Serviceid: {service_id} has been REJECTED" - response = check_contact_delete_in_dos(context["correlation_id"], cms) - assert response is True, "ERROR!!.. Expected Event confirmation in Dos not found." - return context - - -@then(parsers.parse('the Changed Request with changed "{contact}" is captured by Dos')) -def the_changed_contact_is_accepted_by_dos(context, contact): - """assert dos API response and validate processed record in Dos CR Queue database""" - if contact == "phone_no": - cms = "cmstelephoneno" - changed_data = context["change_event"]["Contacts"][1]["ContactValue"] - elif contact == "website": - cms = "cmsurl" - changed_data = context["change_event"]["Contacts"][0]["ContactValue"] - else: - raise ValueError(f"Error!.. Input parameter '{contact}' not compatible") - assert ( - check_received_data_in_dos(context["correlation_id"], cms, changed_data) is True - ), f"ERROR!.. Dos not updated with {contact} change: {changed_data}" - - -@then("the Changed Request with changed specified date and time is captured by Dos") -def the_changed_opening_time_is_accepted_by_dos(context): - """assert dos API response and validate processed record in Dos CR Queue database""" - open_time = time_to_sec(context["change_event"]["OpeningTimes"][-1]["OpeningTime"]) - closing_time = time_to_sec(context["change_event"]["OpeningTimes"][-1]["ClosingTime"]) - changed_time = f"{open_time}-{closing_time}" - changed_date = context["change_event"]["OpeningTimes"][-1]["AdditionalOpeningDate"] - cms = "cmsopentimespecified" - approver_status = confirm_approver_status(context["correlation_id"]) - assert approver_status != [], f'Error!.. Dos Change for correlation id: {context["correlation_id"]} not COMPLETED' - assert ( - check_specified_received_opening_times_date_in_dos(context["correlation_id"], cms, changed_date) is True - ), f"ERROR!.. Dos not updated with change: {changed_date}" - assert ( - check_specified_received_opening_times_time_in_dos(context["correlation_id"], cms, changed_time) is True - ), f"ERROR!.. Dos not updated with change: {changed_time}" - return context - - -@then("the Changed Request with changed standard day time is captured by Dos") -def the_changed_opening_standard_time_is_accepted_by_dos(context): - """assert dos API response and validate processed record in Dos CR Queue database""" - open_time = time_to_sec(context["change_event"]["OpeningTimes"][-2]["OpeningTime"]) - closing_time = time_to_sec(context["change_event"]["OpeningTimes"][-2]["ClosingTime"]) - changed_time = f"{open_time}-{closing_time}" - cms = "cmsopentimemonday" - assert ( - check_standard_received_opening_times_time_in_dos(context["correlation_id"], cms, changed_time) is True - ), f"ERROR!.. Dos not updated with change: {changed_time}" - - -@then("the Changed Request with changed address is captured by Dos") -def the_changed_address_is_accepted_by_dos(context): - """assert dos API response and validate processed record in Dos CR Queue database""" - changed_address = context["change_event"]["Address1"] - assert ( - check_received_data_in_dos(context["correlation_id"], "postaladdress", changed_address) is True - ), f"ERROR!.. Dos not updated with address change: {changed_address}" - - -@then("the Changed Event is not sent to Dos") -def the_changed_event_is_not_sent_to_dos(context): - response = get_changes(context["correlation_id"]) - assert response == [], "ERROR!!.. Event data found in Dos." - - -@then("the event is sent to the DLQ", target_fixture="context") -def event_sender_triggers_DLQ(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter response_text like "Fake Bad Request"' - ) - logs = get_logs(query, "sender", context["start_time"]) - assert "Failed to send change request to DoS" in logs, "ERROR!!.. expected exception logs not found." - return context - - -@then("the DLQ logs the error for Splunk", target_fixture="context") -def event_bridge_dlq_log_check(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter report_key="CR_DLQ_HANDLER_RECEIVED_EVENT"' - ) - logs = get_logs(query, "cr_dlq", context["start_time"]) - assert "Change Request DLQ Handler hit" in logs, "ERROR!!.. expected exception logs not found." - return context - - -@then(parsers.parse('the "{lambda_name}" logs show status code "{status_code}"')) -def lambda_status_code_check(context, lambda_name, status_code): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - f" | filter error_msg_http_code={status_code}" - ) - logs = get_logs(query, lambda_name, context["start_time"]) - assert logs != [], "ERROR!!.. expected DLQ exception logs not found." - - -@then(parsers.parse('the "{lambda_name}" logs show error message "{error_message}"')) -def lambda_error_msg_check(context, lambda_name, error_message): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - f' | filter error_msg like "{error_message}"' - ) - logs = get_logs(query, lambda_name, context["start_time"]) - assert logs != [], "ERROR!!.. expected DLQ exception logs not found." - - -@then(parsers.parse('the change request has status code "{status}"')) -def step_then_should_transform_into(context, status): - message = context["response"].json - assert ( - str(context["response"].status_code) == status - ), f'Status code not as expected: {context["response"].status_code} != {status} Error: {message} - {status}' - - -@then("the attributes for invalid opening times report is identified in the logs") -def invalid_opening_times_exception(context): - query = ( - f'fields @message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - '| filter report_key="INVALID_OPEN_TIMES"' - ) - logs = get_logs(query, "processor", context["start_time"]) - for item in [ - "nhsuk_odscode", - "nhsuk_organisation_name", - "message_received", - "nhsuk_open_times_payload", - "dos_services", - ]: - assert item in logs - - -@then("the date for the specified opening time returns an empty list") -def specified_opening_date_closed(context): - closed_date = context["change_event"]["OpeningTimes"][-1]["AdditionalOpeningDate"] - date_obj = dt.strptime(closed_date, "%b %d %Y").strftime("%Y-%m-%d") - query = f'fields @message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - logs = get_logs(query, "sender", context["start_time"]) - assert f'\\"{date_obj}\\":[]' in logs, f"Expected closed date '{closed_date}' not captured" - return context - - -@then("the day for the standard opening time returns an empty list") -def standard_opening_day_closed(context): - closed_day = context["change_event"]["OpeningTimes"][-2]["Weekday"] - query = f'fields @message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - logs = get_logs(query, "sender", context["start_time"]) - assert f'\\"{closed_day}\\":[]' in logs, f"Expected closed day '{closed_day}' not captured" - return context - - -@then("the stored Changed Event is reprocessed in DI") -def replaying_changed_event(context): - response = re_process_payload(context["change_event"]["ODSCode"], context["sequence_no"]) - assert ( - "The change event has been re-sent successfully" in response - ), f"Error!.. Failed to re-process Change event. Message: {response}" - context["correlation_id"] = ast.literal_eval(loads(response)).get("correlation_id") - return context - - -@then("the reprocessed Changed Event is sent to Dos") -def verify_replayed_changed_event(context): - response = confirm_changes(context["correlation_id"]) - assert response != [], "Error!.. Re-processed change event not found in Dos" - - -@then("the event processor logs should record a sequence error") -def sequence_id_error_logs(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "Sequence id is smaller than the existing one"' - ) - logs = get_logs(query, "processor", context["start_time"]) - assert logs != [], "ERROR!!.. Sequence id error message not found." - - -@then("an invalid opening times error is generated") -def invalid_opening_times_error(context): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter report_key like "INVALID_OPEN_TIMES"' - ) - logs = get_logs(query, "processor", context["start_time"]) - assert "misformatted or illogical set of opening times." in logs, "ERROR!!.. error message not found." - - -@then("the opening times changes are confirmed valid") -def no_opening_times_errors(context): - response = confirm_changes(context["correlation_id"]) - assert "cmsopentime" in str(response), "Error!.. Opening time Change not found in Dos Changes" - - -@then("the Changed Request with special characters is accepted by DOS") -def the_changed_website_is_accepted_by_dos(context): - # the test env uses a 'prod-like' DOS endpoint which rejects these - current_env = getenv("ENVIRONMENT") - if "test" in current_env: - query = ( - "fields response_status_code | sort @timestamp asc" - f' | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "Failed to send change request to DoS"' - ) - logs = get_logs(query, "sender", context["start_time"]) - assert "400" in logs, "ERROR!!.. 400 response not received from DOS" - else: - # the mock DOS currently accepts the invalid characters - uri_timestamp = context["uri_timestamp"] - complete_uri = f"https:\\\\/\\\\/www.rowlandspharmacy.co.uk\\\\/test?foo={uri_timestamp}" # noqa: W605 - query = ( - "fields change_request_body.changes.website | sort @timestamp asc" - f' | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "Attempting to send change request to DoS"' - ) - logs = get_logs(query, "sender", context["start_time"]) - assert complete_uri in logs, "ERROR!!.. website not found in CR." - successquery = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "Successfully send change request to DoS"' - ) - logs = get_logs(successquery, "sender", context["start_time"]) - assert logs != [], "ERROR!!.. successful log messages not showing in cloudwatch." - - -@then("the Changed Event is replayed with the specified opening date deleted") -def change_event_is_replayed(context, valid_or_invalid): - target_date = context["change_event"]["OpeningTimes"][-1]["AdditionalOpeningDate"] - del context["change_event"]["OpeningTimes"][-1] - context["correlation_id"] = f'{context["correlation_id"]}-replay' - context["response"] = process_payload( - context["change_event"], valid_or_invalid == "valid", context["correlation_id"] - ) - context["change_event"]["deleted_date"] = target_date - return context - - -@then("the deleted specified date is confirmed removed from Dos") -def specified_date_is_removed_from_dos(context): - service_id = get_service_id(context["correlation_id"]) - removed_date = dt.strptime(context["change_event"]["deleted_date"], "%b %d %Y").strftime("%y-%m-%d") - approver_status = confirm_approver_status(context["correlation_id"]) - assert approver_status != [], f"Error!.. Dos Change for Serviceid: {service_id} has been REJECTED" - specified_opening_times_from_db = get_change_event_specified_opening_times(service_id) - assert removed_date not in str( - specified_opening_times_from_db - ), f"Error!.. Removed specified date: {removed_date} still exists in Dos" - - -@then(parsers.parse('the Changed Event is replayed with the pharmacy now "{open_or_closed}"')) -def event_replayed_with_pharmacy_closed(context, valid_or_invalid, open_or_closed): - closing_time = datetime.datetime.now().time().strftime("%H:%M") - if open_or_closed.upper() == "OPEN": - context["change_event"]["OpeningTimes"][-2]["OpeningTime"] = "00:01" - context["change_event"]["OpeningTimes"][-2]["ClosingTime"] = closing_time - context["change_event"]["OpeningTimes"][-2]["IsOpen"] = True - context["correlation_id"] = f'{context["correlation_id"]}_open_replay' - elif open_or_closed.upper() == "CLOSED": - context["change_event"]["OpeningTimes"][-2]["OpeningTime"] = "" - context["change_event"]["OpeningTimes"][-2]["ClosingTime"] = "" - context["change_event"]["OpeningTimes"][-2]["IsOpen"] = False - context["correlation_id"] = f'{context["correlation_id"]}_closed_replay' - else: - raise ValueError(f'Invalid status input parameter: "{open_or_closed}"') - context["response"] = process_payload( - context["change_event"], valid_or_invalid == "valid", context["correlation_id"] - ) - return context - - -@then(parsers.parse('the pharmacy is confirmed "{open_or_closed}" for the standard day in Dos')) -def standard_day_confirmed_open(context, open_or_closed): - approver_status = confirm_approver_status(context["correlation_id"]) - assert approver_status != [], "Error!.. Dos Change not Approved or COMPLETED" - service_id = get_service_id(context["correlation_id"]) - opening_time_event = get_change_event_standard_opening_times(service_id) - week_day = context["change_event"]["OpeningTimes"][-2]["Weekday"] - if open_or_closed.upper() == "CLOSED": - assert ( - opening_time_event[week_day] == [] - ), f'ERROR!.. Pharmacy is CLOSED but expected to be OPEN for "{week_day}"' - elif open_or_closed.upper() == "OPEN": - assert ( - opening_time_event[week_day] != [] - ), f'ERROR!.. Pharmacy is OPEN but expected to be CLOSED for "{week_day}"' - else: - raise ValueError(f'Invalid status input parameter: "{open_or_closed}"') - return context - - -@then("the Dentist changes with service type id is captured by Dos") -def dentist_changes_confirmed_in_dos(context): - change_event_service_type = get_service_type_data(context["change_event"]["OrganisationTypeId"])[ - "VALID_SERVICE_TYPES" - ] - change_request_service_type = get_service_type_from_cr(context["correlation_id"]) - assert change_event_service_type[0] == change_request_service_type, "ERROR!.. Service type id mismatch" - - -@then(parsers.parse('the Changed Event finds a matching dentist with ods "{odscode}"')) -def check_logs_for_dentist_match(context, odscode): - query = ( - f'fields message | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "services with typeid in allowlist"' - ) - logs = get_logs(query, "processor", context["start_time"]) - assert odscode in logs, "ERROR!!.. error processor does not have correct ods." - - -@then(parsers.parse('the Event Sender sends the ods "{odscode}"')) -def check_logs_for_correct_sent_cr(context, odscode): - query = ( - f'fields message, ods_code | sort @timestamp asc | filter correlation_id="{context["correlation_id"]}"' - ' | filter message like "Attempting to send change request to DoS"' - ) - logs = get_logs(query, "sender", context["start_time"]) - assert odscode in logs, "ERROR!!.. error sender does not have correct ods." - - -@then(parsers.parse('the Event Processor logs with report key "{report_key}"')) -def check_logs_for_correct_report_key(context, report_key): - query = ( - "fields message, report_key, ods_code | sort @timestamp asc" - f' | filter correlation_id="{context["correlation_id"]}" | filter report_key like "{report_key}"' - ) - logs = get_logs(query, "processor", context["start_time"]) - assert ( - context["change_event"]["ODSCode"] in logs - ), f'ERROR!!.. error event processor did not detect the report key "{report_key}".' - - -@then(parsers.parse('the Event "{processor}" shows field "{field}" with message "{message}"')) -def generic_processor_check_function(context, processor, field, message): - query = ( - f"fields {field} | sort @timestamp asc" - f' | filter correlation_id="{context["correlation_id"]}" | filter {field} like "{message}"' - ) - logs = get_logs(query, processor, context["start_time"]) - assert message in logs, f"ERROR!!.. error event processor did not detect the {field}: {message}." - - -@then(parsers.parse('the Event "{processor}" does not show "{field}" with message "{message}"')) -def generic_processor_negative_check_function(context, processor, field, message): - find_request_id_query = ( - "fields function_request_id | sort @timestamp asc" f' | filter correlation_id="{context["correlation_id"]}"' - ) - find_request_id = loads(get_logs(find_request_id_query, processor, context["start_time"])) - - request_id = "" - for x in find_request_id["results"][0]: - if x["field"] == "function_request_id": - request_id = x["value"] - - finished_check = f'fields @message | filter @requestId == "{request_id}" | filter @type == "END"' - - get_logs(finished_check, processor, context["start_time"], 2) - - query = ( - f"fields {field} | sort @timestamp asc" - f' | filter correlation_id="{context["correlation_id"]}" | filter {field} like "{message}"' - ) - logs_found = negative_log_check(query, processor, context["start_time"]) - - assert logs_found is True, f"ERROR!!.. error event processor did not detect the {field}: {message}." diff --git a/test/integration/steps/test_steps.py b/test/integration/steps/test_steps.py new file mode 100644 index 000000000..5fe220266 --- /dev/null +++ b/test/integration/steps/test_steps.py @@ -0,0 +1,1911 @@ +import ast +from ast import literal_eval +from datetime import datetime as dt +from decimal import Decimal +from json import loads +from os import environ, getenv +from random import randint +from time import sleep + +from faker import Faker +from pytest_bdd import given, scenarios, then, when +from pytest_bdd.parsers import parse +from pytz import timezone + +from .functions.api import process_payload, process_payload_with_sequence +from .functions.assertions import assert_standard_closing, assert_standard_openings +from .functions.aws.aws_lambda import invoke_quality_checker_lambda, re_process_payload +from .functions.aws.cloudwatch import get_logs, negative_log_check +from .functions.aws.dynamodb import get_latest_sequence_id_for_a_given_odscode, get_stored_events_from_dynamo_db +from .functions.aws.s3 import get_s3_email_file +from .functions.aws.sqs import post_to_change_event_dlq, post_ur_fifo, post_ur_sqs +from .functions.context import Context +from .functions.dos.check_data import ( + check_pending_service_is_rejected, + check_service_history, + check_service_history_change_type, + service_history_negative_check, +) +from .functions.dos.get_data import ( + get_blood_pressure_sgsd, + get_change_event_specified_opening_times, + get_change_event_standard_opening_times, + get_contraception_sgsd, + get_locations_table_data, + get_palliative_care, + get_service_history, + get_service_history_specified_opening_times, + get_service_history_standard_opening_times, + get_service_id, + get_service_table_field, + get_services_table_location_data, + wait_for_service_update, +) +from .functions.dos.translation import get_service_table_field_name, get_status_id +from .functions.generator import ( + add_blood_pressure_to_change_event, + add_contraception_to_change_event, + add_palliative_care_to_change_event, + add_single_opening_day, + add_specified_openings_to_dos, + add_standard_openings_to_dos, + apply_blood_pressure_to_service, + apply_contraception_to_service, + apply_palliative_care_to_service, + build_change_event, + build_change_event_contacts, + build_change_event_opening_times, + build_change_event_services, + commit_new_service_to_dos, + generate_staff, + query_specified_opening_builder, + query_standard_opening_builder, + remove_palliative_care_to_change_event, + valid_change_event, +) +from .functions.slack import slack_retry +from .functions.utils import ( + convert_specified_opening, + convert_standard_opening, + create_pending_change_for_service, + generate_correlation_id, + generate_random_int, + generate_unique_ods_code, + get_address_string, + get_expected_data, + quality_checker_log_check, + quality_checker_negative_log_check, +) + +scenarios( + "../features/F001_Valid_Change_Events.feature", + "../features/F002_Invalid_Change_Events.feature", + "../features/F003_DoS_Security.feature", + "../features/F004_Error_Handling.feature", + "../features/F005_Support_Functions.feature", + "../features/F006_Opening_Times.feature", + "../features/F007_Reporting.feature", + "../features/F008_Quality_Checker.feature", +) +FAKER = Faker("en_GB") + + +@given(parse('the "{contact}" is changed and is valid'), target_fixture="context") +def a_changed_contact_event_is_valid(contact: str, context: Context) -> Context: + """Change a contact and validate the change event. + + Args: + contact (str): The contact to change. + context (Context): The context object. + + Returns: + Context: The context object. + """ + validated = False + while not validated: + match contact.lower(): + case "website": + context.previous_value = context.change_event["Contacts"][1]["ContactValue"] + context.website = f"{FAKER.domain_word()}.nhs.uk" + context.generator_data["web"] = context.website + context.change_event["Contacts"] = build_change_event_contacts(context) + case "phone_no": + context.previous_value = context.change_event["Contacts"][0]["ContactValue"] + context.phone = FAKER.phone_number() + context.generator_data["publicphone"] = context.phone + context.change_event["Contacts"] = build_change_event_contacts(context) + case "address": + context.previous_value = get_address_string(context) + context.change_event["Address1"] = FAKER.street_name() + case _: + msg = f"ERROR!.. Input parameter '{contact}' not compatible" + raise ValueError(msg) + validated = valid_change_event(context) + return context + + +@given("an entry is created in the services table", target_fixture="context") +def a_service_table_entry_is_created(context: Context, ods_code: int = 0, service_type: int = 13) -> Context: + """Create a new entry in the services table. + + Args: + context (Context): The context object. + ods_code (int, optional): The ODS code to use. Defaults to 0. + service_type (int, optional): The service type to use. Defaults to 13. + + Returns: + Context: The context object. + """ + if ods_code == 0: + ods_code = generate_unique_ods_code() + query_values = { + "uid": f"test{randint(10000, 99999)!s}", + "service_type": service_type, + "service_status": 1, + "name": f"Test Pharmacy {randint(100, 999)!s}", + "odscode": ods_code, + "address": f"{randint(100, 999)!s} Test Address", + "town": "Nottingham", + "postcode": "NG11GS", + "publicphone": f"{randint(10000000000, 99999999999)!s}", + "web": "www.google.com", + "blood pressure": False, + "contraception": False, + } + context.generator_data = query_values + return context + + +@given("the service in DoS supports palliative care", target_fixture="context") +def add_palliative_care_to_dos(context: Context) -> Context: + """Add a palliative care entry to the service in DoS. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.other = apply_palliative_care_to_service(context) + return context + + +@given("the change event has a palliative care entry", target_fixture="context") +def _(context: Context) -> Context: + """Add a palliative care uecservice to the change event. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + add_palliative_care_to_change_event(context) + return context + + +@given("the change event has no palliative care entry", target_fixture="context") +def _(context: Context) -> Context: + """Remove a palliative care uecservice to the change event. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + remove_palliative_care_to_change_event(context) + return context + + +@given("the change event has a blood pressure entry", target_fixture="context") +def _(context: Context) -> Context: + """Add a blood pressure service entry to the change event. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + add_blood_pressure_to_change_event(context) + return context + + +@given("the change event has a contraception entry", target_fixture="context") +def _(context: Context) -> Context: + """Add a contraception service entry to the change event. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + add_contraception_to_change_event(context) + return context + + +@given(parse('"{count}" basic services are created'), target_fixture="context") +def create_multiple_basic_service_entry(context: Context, count: str) -> Context: + """Create multiple basic services. + + Args: + context (Context): The context object. + count (str): The number of services to create. + + Returns: + Context: The context object. + """ + context = a_service_table_entry_is_created(context) + context = service_table_entry_is_committed(context) + ods_code = context.generator_data["odscode"] + for _ in range(int(count) - 1): + context = a_service_table_entry_is_created(context, ods_code) + context = service_table_entry_is_committed(context) + return context + + +@given("a basic service is created", target_fixture="context") +def create_basic_service_entry(context: Context) -> Context: + """Create a basic service. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context = a_service_table_entry_is_created(context) + return service_table_entry_is_committed(context) + + +@given(parse('a {service_name} service is created with type "{service_type:d}"'), target_fixture="context") +def _(context: Context, service_type: int) -> Context: + """Create a basic service with a specific service type. + + Args: + context (Context): The context object. + service_type (int): The service type to use. + + Returns: + Context: The context object. + """ + context = a_service_table_entry_is_created(context, service_type=service_type) + return service_table_entry_is_committed(context) + + +@given( + parse('a basic service is created with "{odscode_character_length:d}" character odscode'), + target_fixture="context", +) +def _(context: Context, odscode_character_length: int) -> Context: + """Create a basic service with a specific osdcode length. + + Args: + context (Context): The context object. + odscode_character_length (int): The length of the odscode to use. + service_type (int): The service type to use. + + Returns: + Context: The context object. + """ + min_value = f"1{'0'* (odscode_character_length-1)} " + max_value = "9" * odscode_character_length + odscode = randint(int(min_value), int(max_value)) + context = a_service_table_entry_is_created(context, ods_code=odscode) + context = service_table_entry_is_committed(context) + short_odscode = str(odscode)[:5] + context.ods_code = short_odscode + context.generator_data["odscode"] = short_odscode + context.change_event["ODSCode"] = short_odscode + return context + + +@given( + parse( + 'a pharmacy service is created with "{odscode_character_length:d}" character odscode ' + 'and type "{service_type:d}"', + ), + target_fixture="context", +) +def _(context: Context, odscode_character_length: int, service_type: int) -> Context: + """Create a basic service with a specific service type and an ods code of a certain length. + + Args: + context (Context): The context object. + odscode_character_length (int): The length of the odscode to use. + service_type (int): The service type to use. + + Returns: + Context: The context object. + """ + min_value = f"1{'0'* (odscode_character_length-1)} " + max_value = "9" * odscode_character_length + odscode = randint(int(min_value), int(max_value)) + context = a_service_table_entry_is_created(context, ods_code=odscode, service_type=service_type) + context = service_table_entry_is_committed(context) + short_odscode = str(odscode)[:5] + context.ods_code = short_odscode + context.generator_data["odscode"] = short_odscode + context.change_event["ODSCode"] = short_odscode + return context + + +@given(parse('the service "{field_name}" is set to "{values}"'), target_fixture="context") +def service_values_updated_in_context(field_name: str, values: str, context: Context) -> Context: + """Update the service values in the context object. + + Args: + field_name (str): The field name to update. + values (str): The values to update the field with. + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.generator_data[field_name] = values + return context + + +@given("an entry is created in the services table with a derivative odscode", target_fixture="context") +def _(context: Context) -> Context: + odscode = f"{context.generator_data['odscode']}A" + return a_service_table_entry_is_created(context=context, ods_code=odscode) + + +@given("an entry is created in the services table with a derivative service", target_fixture="context") +def _(context: Context) -> Context: + context.generator_data["odscode"] = f"{context.generator_data['odscode']}A" + context.generator_data["id"] = f"{context.generator_data['id']}1" + context.generator_data["uid"] = f"{context.generator_data['uid']}1" + return context + + +@given(parse('the service is "{service_status}" on "{day}"'), target_fixture="context") +def service_standard_opening_set(service_status: str, day: str, context: Context) -> Context: + """Set the service standard opening times. + + Args: + service_status (str): The service status to set. + day (str): The day to set the service status for. + context (Context): The context object. + + Returns: + Context: The context object. + """ + if day.lower() not in ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]: + msg = "Selected day is not valid" + raise ValueError(msg) + query_standard_opening_builder(context, service_status, day) + return context + + +@given(parse('the change event is "{service_status}" on "{day}"'), target_fixture="context") +def change_event_standard_opening_set(service_status: str, day: str, context: Context) -> Context: + """Set the change event standard opening times. + + Args: + service_status (str): The service status to set. + day (str): The day to set the service status for. + context (Context): The context object. + + Returns: + Context: The context object. + """ + if day.lower() not in ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]: + msg = "Selected day is not valid" + raise ValueError(msg) + query_standard_opening_builder(context, service_status, day) + context.change_event["OpeningTimes"] = build_change_event_opening_times(context) + return context + + +@given(parse('the service is "{service_status}" on date "{date}"'), target_fixture="context") +def service_specified_opening_set(service_status: str, date: str, context: Context) -> Context: + """Set the service specified opening times. + + Args: + service_status (str): The service status to set. + date (str): The date to set the service status for. + context (Context): The context object. + + Returns: + Context: The context object. + """ + query_specified_opening_builder(context, service_status, date) + return context + + +@given(parse('the change event is "{service_status}" on date "{date}"'), target_fixture="context") +def change_event_specified_opening_set(service_status: str, date: str, context: Context) -> Context: + """Set the change event specified opening times. + + Args: + service_status (str): The service status to set. + date (str): The date to set the service status for. + context (Context): The context object. + + Returns: + Context: The context object. + """ + query_specified_opening_builder(context, service_status, date) + context.change_event["OpeningTimes"] = build_change_event_opening_times(context) + return context + + +@given( + parse( + 'the change event specified opening is "{service_status}" from' + ' "{open_time}" to "{close_time}" on date "{date}"', + ), + target_fixture="context", +) +def change_event_specified_opening_set_times( + service_status: str, + date: str, + open_time: str, + close_time: str, + context: Context, +) -> Context: + """Set the change event specified opening times. + + Args: + service_status (str): The service status to set. + date (str): The date to set the service status for. + open_time (str): The opening time to set. + close_time (str): The closing time to set. + context (Context): The context object. + + Returns: + Context: The context object. + """ + query_specified_opening_builder(context, service_status, date, open_time, close_time) + context.change_event["OpeningTimes"] = build_change_event_opening_times(context) + return context + + +@given("the change event has no standard opening times", target_fixture="context") +def _(context: Context) -> Context: + """Remove the specified opening dates from the change event. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.generator_data["standard_openings"] = [] + context.change_event["OpeningTimes"] = build_change_event_opening_times(context) + return context + + +@given("the change event has no specified opening dates", target_fixture="context") +def change_event_no_specified_opening_dates(context: Context) -> Context: + """Remove the specified opening dates from the change event. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + date_vals = context.generator_data["specified_openings"][0] + context.other = { + "AdditionalOpeningDate": date_vals["date"], + "OpeningTime": date_vals["opening_time"], + "ClosingTime": date_vals["closing_time"], + } + context.generator_data["specified_openings"] = [] + context.change_event["OpeningTimes"] = build_change_event_opening_times(context) + return context + + +@given("the entry is committed to the services table", target_fixture="context") +def service_table_entry_is_committed(context: Context) -> Context: + """Commit the entry to the services table. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context = commit_new_service_to_dos(context) + ce_state = False + if "standard_openings" in context.generator_data: + add_standard_openings_to_dos(context) + ce_state = True + if "specified_openings" in context.generator_data: + add_specified_openings_to_dos(context) + if context.change_event is None: + build_change_event(context) + if not ce_state: + add_single_opening_day(context) + context.change_event["OpeningTimes"] = build_change_event_opening_times(context) + return context + + +@given(parse('the change event "{field_name}" is set to "{values}"'), target_fixture="context") +def ce_values_updated_in_context(field_name: str, values: str, context: Context) -> Context: + """Update the change event values in the context. + + Args: + field_name (str): The field name to update. + values (str): The values to update the field with. + context (Context): The context object. + + Returns: + Context: The context object. + """ + if field_name.lower() == "website": + context.previous_value = context.generator_data["web"] + context.generator_data["web"] = values + context.change_event["Contacts"] = build_change_event_contacts(context) + elif field_name.lower() == "phone": + values = values.replace('"', "") + context.previous_value = context.generator_data["publicphone"] + context.generator_data["publicphone"] = values + context.change_event["Contacts"] = build_change_event_contacts(context) + elif field_name.lower() == "blood pressure": + context.generator_data["blood pressure"] = literal_eval(values) + context.change_event["Services"] = build_change_event_services(context) + elif field_name.lower() == "contraception": + context.generator_data["contraception"] = literal_eval(values) + context.change_event["Services"] = build_change_event_services(context) + else: + context.previous_value = context.change_event[field_name] + context.change_event[field_name] = values + return context + + +@given("the change event staff field is populated", target_fixture="context") +def ce_staff_field_populated(context: Context) -> Context: + """Populate the change event staff field. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.change_event["Staff"] = generate_staff() + return context + + +@given("the change event has no staff field", target_fixture="context") +def ce_staff_field_removed(context: Context) -> Context: + """Remove the change event staff field. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + del context.change_event["Staff"] + return context + + +@given(parse('the specified opening date is set to "{future_past}" date'), target_fixture="context") +def future_set_specified_opening_date(future_past: str, context: Context) -> Context: + """Set the specified opening date to a future or past date. + + Args: + future_past (str): The future or past date to set. + context (Context): The context object. + + Returns: + Context: The context object. + """ + year = 0 + if future_past.lower() == "future": + year = dt.now(tz=timezone("Europe/London")).year + 1 + context.change_event["OpeningTimes"].append( + { + "Weekday": "", + "OpeningTime": "08:00", + "ClosingTime": "16:00", + "OffsetOpeningTime": 0, + "OffsetClosingTime": 0, + "OpeningTimeType": "Additional", + "AdditionalOpeningDate": f"Jan 10 {year}", + "IsOpen": True, + }, + ) + else: + for days in context.change_event["OpeningTimes"]: + if days["OpeningTimeType"] == "Additional": + context.change_event["OpeningTimes"].remove(days) + return context + + +@given("a pending entry exists in the changes table for this service", target_fixture="context") +def change_table_entry_creation_for_service(context: Context) -> Context: + """Create a pending entry in the changes table for the service. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + service_id = context.generator_data["id"] + service_uid = context.generator_data["uid"] + context.service_uid = service_uid + create_pending_change_for_service(service_id) + return context + + +@given(parse('the "{contact}" value has been unset'), target_fixture="context") +def changed_event_contact_removed(contact: str, context: Context) -> Context: + """Remove the contact from the change event. + + Args: + contact (str): The contact to remove. + context (Context): The context object. + + Returns: + Context: The context object. + """ + match contact.lower(): + case "website": + context.previous_value = context.generator_data["web"] + context.generator_data["web"] = None + context.change_event["Contacts"] = build_change_event_contacts(context) + case "phone": + context.previous_value = context.generator_data["publicphone"] + context.generator_data["publicphone"] = None + context.change_event["Contacts"] = build_change_event_contacts(context) + case _: + msg = f"Invalid contact '{contact}' provided" + raise ValueError(msg) + return context + + +@given(parse('the Changed Event has "{amount}" break in opening times'), target_fixture="context") +def change_event_with_break_in_opening_times(context: Context, amount: str) -> Context: + """Create a change event with a break in the opening times. + + Args: + context (Context): The context object. + amount (str): The amount of breaks to add. + + Returns: + Context: The context object. + """ + context.generator_data["standard_openings"] = [] + if amount in {"1", "2", "3"}: + query_standard_opening_builder(context, "open", "monday", "09:00", "12:00") + query_standard_opening_builder(context, "open", "monday", "12:30", "16:00") + if amount in {"2", "3"}: + query_standard_opening_builder(context, "open", "monday", "16:10", "16:30") + if amount in {"3"}: + query_standard_opening_builder(context, "open", "monday", "16:40", "17:00") + context.change_event["OpeningTimes"] = build_change_event_opening_times(context) + return context + + +# Weekday NOT present on the Opening Time +@given("the change event has no weekday present in opening times", target_fixture="context") +def a_change_event_with_no_openingtimes_weekday(context: Context) -> Context: + """Remove the weekday from the change event opening times. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + del context.change_event["OpeningTimes"][0]["Weekday"] + return context + + +# OpeningTimeType is NOT "General" or "Additional" +@given("the change event has an invalid openingtimetype", target_fixture="context") +def a_change_event_with_invalid_openingtimetype(context: Context) -> Context: + """Set the opening time type to an invalid value. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.change_event["OpeningTimes"][0]["OpeningTimeType"] = "F8k3" + return context + + +# IsOpen is true AND Times is blank +@given("the change event has undefined opening and closing times", target_fixture="context") +def no_times_data_within_openingtimes(context: Context) -> Context: + """Remove the opening and closing times from the change event. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.change_event["OpeningTimes"][0]["OpeningTime"] = "" + context.change_event["OpeningTimes"][0]["ClosingTime"] = "" + return context + + +# isOpen is false AND Times in NOT blank +@given("the change event has opening times open status set to false", target_fixture="context") +def a_change_event_with_isopen_status_set_to_false(context: Context) -> Context: + """Set the opening times open status to false. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.change_event["OpeningTimes"][0]["IsOpen"] = False + return context + + +# OpeningTimeType is Additional AND AdditionalOpening Date is Blank +@given("the change event has an additional date with no specified date", target_fixture="context") +def specified_opening_date_not_defined(context: Context) -> Context: + """Remove the additional opening date from the change event. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.change_event["OpeningTimes"].append( + { + "AdditionalOpeningDate": "", + "ClosingTime": "12:00", + "IsOpen": True, + "OffsetClosingTime": 780, + "OffsetOpeningTime": 540, + "OpeningTime": "09:00", + "OpeningTimeType": "Additional", + "Weekday": "", + }, + ) + return context + + +@given(parse('the correlation-id is "{custom_correlation}"'), target_fixture="context") +def a_custom_correlation_id_is_set(context: Context, custom_correlation: str) -> Context: + """Set the correlation id to a custom value. + + Args: + context (Context): The context object. + custom_correlation (str): The custom correlation id. + + Returns: + Context: The context object. + """ + context.correlation_id = generate_correlation_id(custom_correlation) + return context + + +@given("the ODS has an entry in dynamodb", target_fixture="context") +def create_ods_in_ddb(context: Context) -> Context: + """Create an ODS entry in dynamodb. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context = the_change_event_is_sent_with_custom_sequence(context, 100) + context.sequence_number = 100 + context.unique_key = generate_random_int() + return context + + +@given("the Changed Event has blank opening times", target_fixture="context") +def change_event_with_blank_opening_times(context: Context) -> Context: + """Create a change event with blank opening times. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.change_event["OpeningTimes"] = [] + return context + + +@when(parse('the change event is "{service_status}" on date "{date}"'), target_fixture="context") +def change_event_specified_opening(service_status: str, date: str, context: Context) -> Context: + """Set the change event specified opening times. + + Args: + service_status (str): The service status to set. + date (str): The date to set the service status for. + context (Context): The context object. + + Returns: + Context: The context object. + """ + query_specified_opening_builder(context, service_status, date) + context.change_event["OpeningTimes"] = build_change_event_opening_times(context) + return context + + +@when(parse('the change event "{field_name}" is set to "{values}"'), target_fixture="context") +def _(field_name: str, values: str, context: Context) -> Context: + """Update the change event values in the context. + + Args: + field_name (str): The field name to update. + values (str): The values to update the field with. + context (Context): The context object. + + Returns: + Context: The context object. + """ + return ce_values_updated_in_context(field_name, values, context) + + +@when(parse('a "{queue_type}" SQS message is added to the queue'), target_fixture="context") +def post_an_sqs_message(queue_type: str, context: Context) -> None: + """Post an SQS message to the queue. + + Args: + queue_type (str): The type of queue to post to. + context (Context): The context object. + """ + match queue_type.lower(): + case "change event dlq": + post_to_change_event_dlq(context) + case "update request dlq": + post_ur_sqs() + case "update request failure": + post_ur_fifo() + case _: + msg = f"ERROR!.. queue type '{queue_type}' is not valid" + raise ValueError(msg) + + +@when(parse('the Changed Event is sent for processing with "{valid_or_invalid}" api key'), target_fixture="context") +def the_change_event_is_sent_for_processing(context: Context, valid_or_invalid: str) -> Context: + """Send the change event for processing. + + Args: + context (Context): The context object. + valid_or_invalid (str): The type of api key to use. + + Returns: + Context: The context object. + """ + if context.phone is not None or context.website is not None: + context.change_event["Contacts"] = build_change_event_contacts(context) + context.start_time = dt.now(tz=timezone("Europe/London")).timestamp() + context.correlation_id = generate_correlation_id() + context.response = process_payload(context, valid_or_invalid == "valid", context.correlation_id) + context.sequence_number = context.response.request.headers["sequence-number"] + return context + + +# Request with custom sequence id +@when( + parse('the Changed Event is sent for processing with sequence id "{seqid}"'), + target_fixture="context", +) +def the_change_event_is_sent_with_custom_sequence(context: Context, seqid: str) -> Context: + """Send the change event for processing with a custom sequence id. + + Args: + context (Context): The context object. + seqid (str): The custom sequence id. + + Returns: + Context: The context object. + """ + context.start_time = dt.now(tz=timezone("Europe/London")).timestamp() + context.correlation_id = generate_correlation_id() + context.response = process_payload_with_sequence(context, context.correlation_id, seqid) + context.sequence_number = seqid + context.change_event["Address1"] = context.change_event["Address1"] + " - Updated" + return context + + +# Request with no sequence id +@when( + parse("the Changed Event is sent for processing with no sequence id"), + target_fixture="context", +) +def the_change_event_is_sent_with_no_sequence(context: Context) -> Context: + """Send the change event for processing with no sequence id. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.start_time = dt.now(tz=timezone("Europe/London")).timestamp() + context.correlation_id = generate_correlation_id() + context.response = process_payload_with_sequence(context, context.correlation_id, None) + return context + + +# Request with duplicate sequence id +@when( + parse("the Changed Event is sent for processing with a duplicate sequence id"), + target_fixture="context", +) +def the_change_event_is_sent_with_duplicate_sequence(context: Context) -> Context: + """Send the change event for processing with a duplicate sequence id. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.start_time = dt.now(tz=timezone("Europe/London")).timestamp() + context.correlation_id = generate_correlation_id() + context.change_event["Address1"] = "New Test Address Value" + seqid = 0 + seqid = 100 if context.sequence_number == 100 else get_latest_sequence_id_for_a_given_odscode(context.ods_code) + context.response = process_payload_with_sequence(context, context.correlation_id, seqid) + context.sequence_number = seqid + return context + + +@then("the Changed Event is stored in dynamo db") +def stored_dynamo_db_events_are_pulled(context: Context) -> Context: + """Pull the stored dynamo db events. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + odscode = context.change_event["ODSCode"] + sequence_num = Decimal(context.sequence_number) + sleep(15) + db_event_record = get_stored_events_from_dynamo_db(odscode, sequence_num) + assert db_event_record is not None, f"ERROR!! Event record with odscode {odscode} NOT found!.." + assert ( + odscode == db_event_record["ODSCode"] + ), f"ERROR!!.. Change event record({odscode} - {db_event_record['ODSCode']}) mismatch!!" + assert sequence_num == db_event_record["SequenceNumber"], "ERROR!!.. Change event record(sequence no) mismatch!!" + return context + + +@then(parse('the "{plain_english_service_table_field}" has not been changed in DoS')) +def field_is_not_updated_in_dos(context: Context, plain_english_service_table_field: str) -> None: + """Assert DoS demographics data is not updated. + + Args: + context (Context): The context object. + plain_english_service_table_field (str): The plain english service table field. + """ + sleep(60) + field_name = get_service_table_field_name(plain_english_service_table_field) + field_data = get_service_table_field(service_id=context.service_id, field_name=field_name) + assert ( + context.previous_value in field_data + ), f"ERROR!.. DoS doesn't have expected {plain_english_service_table_field} data - It has changed from expected value, expected: {context.previous_value}, actual: {field_data}" # noqa: E501 + + +@then(parse('DoS has "{expected_data}" in the "{plain_english_service_table_field}" field')) +def expected_data_is_within_dos(context: Context, expected_data: str, plain_english_service_table_field: str) -> None: + """Assert DoS demographics data is updated. + + Args: + context (Context): The context object. + expected_data (str): The expected data. + plain_english_service_table_field (str): The plain english service table field. + """ + wait_for_service_update(context.service_id) + field_name = get_service_table_field_name(plain_english_service_table_field) + field_data = get_service_table_field(service_id=context.service_id, field_name=field_name) + if plain_english_service_table_field in {"easting", "northing", "status"}: + expected_data = int(expected_data) + elif plain_english_service_table_field in {"latitude", "longitude"}: + expected_data = float(expected_data) + elif plain_english_service_table_field in {"phone"}: + expected_data = expected_data.replace(" ", "") + assert ( + field_data == expected_data + ), f"ERROR!.. DoS doesn't have expected {plain_english_service_table_field} data, expected: {expected_data}, actual: {field_data}" # noqa: E501 + + +@then(parse('the "{plain_english_service_table_field}" is updated within the DoS DB')) +def check_the_service_table_field_has_updated(context: Context, plain_english_service_table_field: str) -> Context: + """Assert DoS demographics data is updated. + + Args: + context (Context): The context object. + plain_english_service_table_field (str): The plain english service table field. + + Returns: + Context: The context object. + """ + wait_for_service_update(context.service_id) + field_name = get_service_table_field_name(plain_english_service_table_field) + field_data = get_service_table_field(service_id=context.service_id, field_name=field_name) + expected_value = get_expected_data(context, plain_english_service_table_field) + expected_value = expected_value if expected_value is not None else "" + assert field_data == expected_value, ( + f"ERROR!!.. Expected {plain_english_service_table_field} not found in Dos DB., " + f"expected: {expected_value}, found: {field_data}" + ) + return context + + +@then(parse('the service history is updated with the "{plain_english_service_table_field}"')) +def check_the_service_history_has_updated(context: Context, plain_english_service_table_field: str) -> Context: + """Assert DoS demographics data is updated. + + Args: + context (Context): The context object. + plain_english_service_table_field (str): The plain english service table field. + + Returns: + Context: The context object. + """ + expected_data = get_expected_data(context, plain_english_service_table_field) + if context.previous_value in ["", "unknown"]: + context.previous_value = "unknown" + check_service_history( + service_id=context.service_id, + plain_english_field_name=plain_english_service_table_field, + expected_data=expected_data, + previous_data=context.previous_value, + ) + return context + + +@then(parse('the service history is updated with the "{added_or_removed}" specified opening times')) +def check_service_history_specified_times(context: Context, added_or_removed: str) -> Context: + """Assert DoS specified opening times data is updated. + + Args: + context (Context): The context object. + added_or_removed (str): The added or removed. + + Returns: + Context: The context object. + """ + match added_or_removed: + case "added": + change_type = "add" + case "removed": + change_type = "remove" + case "closed": + change_type = "add" + case _: + msg = "Invalid change type has been provided" + raise ValueError(msg) + if change_type == "add": + openingtimes = context.change_event["OpeningTimes"][-1] + elif change_type == "remove": + openingtimes = context.other + dos_times = get_service_history_specified_opening_times(context.service_id) + changed_dates = dos_times["data"][change_type] + if added_or_removed == "closed": + expected_dates = convert_specified_opening(openingtimes, True) + else: + expected_dates = convert_specified_opening(openingtimes) + assert expected_dates in changed_dates, f"{expected_dates}" + return context + + +@then(parse('the service history is updated with the "{added_or_removed}" standard opening times')) +def check_service_history_standard_times(context: Context, added_or_removed: str) -> Context: + """Assert DoS standard opening times data is updated. + + Args: + context (Context): The context object. + added_or_removed (str): The added or removed. + + Returns: + Context: The context object. + """ + sleep(10) + openingtimes = context.change_event["OpeningTimes"] + dos_times = get_service_history_standard_opening_times(context.service_id) + expected_dates = convert_standard_opening(openingtimes) + counter = 0 + strict_checks = "f006s012" in environ.get("PYTEST_CURRENT_TEST") + if added_or_removed == "added": + counter = assert_standard_openings("add", dos_times, expected_dates, strict_checks) + elif added_or_removed == "modified": + counter = assert_standard_openings("modify", dos_times, expected_dates, strict_checks) + else: + counter = assert_standard_closing(dos_times, expected_dates) + if counter == 0: + msg = "ERROR: No Assertions have been made" + raise ValueError(msg) + return context + + +@then(parse("the service history is not updated")) +def check_service_history_not_updated(context: Context) -> Context: + """Assert DoS service history is not updated. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + service_history_status = service_history_negative_check(context.service_id) + assert service_history_status == "Not Updated", "ERROR: Service history was unexpectedly updated" + # will revisit to change the assertion type to boolean rather string + return context + + +@then(parse('the service history shows change type is "{change_type}"')) +def check_service_history_for_change_type(context: Context, change_type: str) -> Context: + """Assert DoS service history change type is updated. + + Args: + context (Context): The context object. + change_type (str): The change type. + + Returns: + Context: The context object. + """ + # This brings instability if more than one entry has been changed + change_status = check_service_history_change_type(context.service_id, change_type) + assert change_status == "Change type matches", f"ERROR: Expected {change_type} but {change_status}" + return context + + +@then(parse('the service history shows "{field_name}" change type is "{change_type}"')) +def check_service_history_for_specific_change_type(context: Context, change_type: str, field_name: str) -> Context: + """Assert DoS service history change type is updated. + + Args: + context (Context): The context object. + change_type (str): The change type. + field_name (str): The field name. + + Returns: + Context: The context object. + """ + change_status = check_service_history_change_type(context.service_id, change_type, field_name) + assert change_status == "Change type matches", f"ERROR: Expected {change_type} but {change_status}" + return context + + +@then("the DoS service has been updated with the specified date and time is captured by DoS") +def the_dos_service_has_been_updated_with_the_specified_date_and_time_is_captured_by_dos(context: Context) -> Context: + """Assert DoS service has been updated with the specified date and time is captured by DoS. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.service_id = get_service_id(context.change_event["ODSCode"]) + wait_for_service_update(context.service_id) + opening_time = context.change_event["OpeningTimes"][-1]["OpeningTime"] + closing_time = context.change_event["OpeningTimes"][-1]["ClosingTime"] + changed_date = context.change_event["OpeningTimes"][-1]["AdditionalOpeningDate"] + current_specified_openings = get_change_event_specified_opening_times(context.service_id) + expected_opening_date = dt.strptime(changed_date, "%b %d %Y").strftime("%Y-%m-%d") + assert expected_opening_date in current_specified_openings, "DoS not updated with specified opening time" + assert current_specified_openings[expected_opening_date][0]["start_time"] == opening_time + assert current_specified_openings[expected_opening_date][0]["end_time"] == closing_time + + +@then("the DoS service has been updated with the specified date is captured by DoS") +def the_dos_service_has_been_updated_with_the_specified_date_is_captured_by_dos(context: Context) -> Context: + """Assert DoS service has been updated with the specified date and time is captured by DoS. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.service_id = get_service_id(context.change_event["ODSCode"], context.generator_data["service_type"]) + wait_for_service_update(context.service_id) + changed_date = context.change_event["OpeningTimes"][-1]["AdditionalOpeningDate"] + current_specified_openings = get_change_event_specified_opening_times(context.service_id) + expected_opening_date = dt.strptime(changed_date, "%b %d %Y").strftime("%Y-%m-%d") + assert expected_opening_date in current_specified_openings, "DoS not updated with specified opening time" + + +@then(parse('the DoS DB has no open date in "{year}"')) +def the_dos_service_has_no_past_openings(context: Context, year: str) -> None: + """Assert DoS service has no past openings. + + Args: + context (Context): The context object. + year (str): The year. + """ + wait_for_service_update(context.generator_data["id"]) + current_specified_openings = get_change_event_specified_opening_times(context.generator_data["id"]) + if current_specified_openings != {}: + assert year not in current_specified_openings, f"{year} not found in {current_specified_openings}" + + +@then("the DoS service has been updated with the standard days and times is captured by DoS") +def the_dos_service_has_been_updated_with_the_standard_days_and_times_is_captured_by_dos(context: Context) -> None: + """Assert DoS service has been updated with the standard days and times is captured by DoS. + + Args: + context (Context): The context object. + """ + context.service_id = get_service_id(context.change_event["ODSCode"]) + wait_for_service_update(context.service_id) + open_time = context.change_event["OpeningTimes"][0]["OpeningTime"] + closing_time = context.change_event["OpeningTimes"][0]["ClosingTime"] + current_standard_openings = get_change_event_standard_opening_times(context.service_id) + assert "Monday" in current_standard_openings, "DoS not updated with standard opening time" + assert current_standard_openings["Monday"][0]["start_time"] == open_time + assert current_standard_openings["Monday"][0]["end_time"] == closing_time + + +@then(parse('the change event response has status code "{status}"')) +def step_then_should_transform_into(context: Context, status: str) -> None: + """Assert the change event response has status code. + + Args: + context (Context): The context object. + status (str): The status code. + """ + message = context.response.json + assert ( + str(context.response.status_code) == status + ), f"Status code not as expected: {context.response.status_code} != {status} Error: {message} - {status}" + + +@then("the response has security headers") +def step_then_security_headers_are_present(context: Context) -> None: + """Assert the change event response has security headers. + + Args: + context (Context): The context object. + """ + expected_headers = { + "X-Frame-Options": "DENY", + "Content-Security-Policy": "default-src 'self'", + "X-Content-Type-Options": "nosniff", + } + + for header, expected_value in expected_headers.items(): + assert header in context.response.headers, f"'{header}' header is missing in the response" + assert ( + context.response.headers[header] == expected_value + ), f"'{header}' value is not as expected: {context.response.headers[header]} != {expected_value}" + + +@then(parse('"{attribute}" attribute is identified in the "{report}" report in "{lambda_name}" logs')) +def step_then_attribute_is_identified_in_the_report( + context: Context, + attribute: str, + report: str, + lambda_name: str, +) -> None: + """Assert the attribute is identified in the report in lambda logs. + + Args: + context (Context): The context object. + attribute (str): Attribute name. + report (str): Report name. + lambda_name (str): Lambda name. + """ + query = ( + f'fields @message | sort @timestamp asc | filter correlation_id="{context.correlation_id}"' + f'| filter report_key="{report}"' + ) + logs = get_logs(query, lambda_name, context.start_time) + assert attribute in logs + + +@then("the stored Changed Event is reprocessed in DI") +def replaying_changed_event(context: Context) -> Context: + """Assert the stored Changed Event is reprocessed in DI. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + response = re_process_payload(context.change_event["ODSCode"], context.sequence_number) + assert ( + "The change event has been re-sent successfully" in response + ), f"Error!.. Failed to re-process Change event. Message: {response}" + context.correlation_id = ast.literal_eval(loads(response)).get("correlation_id") + return context + + +@then("opening times with a break are updated in DoS") +def opening_times_with_a_break_are_updated_in_dos(context: Context) -> None: + """Assert opening times with a break are updated in DoS. + + Args: + context (Context): The context object. + """ + context.service_id = get_service_id(context.change_event["ODSCode"]) + wait_for_service_update(context.service_id) + current_standard_openings = get_change_event_standard_opening_times(context.service_id) + assert "Monday" in current_standard_openings, "DoS not updated with standard opening time" + assert current_standard_openings["Monday"][0]["start_time"] == "09:00" + assert current_standard_openings["Monday"][0]["end_time"] == "12:00" + assert current_standard_openings["Monday"][1]["start_time"] == "12:30" + assert current_standard_openings["Monday"][1]["end_time"] == "16:00" + assert len(current_standard_openings["Monday"]) == 2, "Expected 2 opening times" + + +@then("opening times with two breaks are updated in DoS") +def opening_times_with_two_breaks_are_updated_in_dos(context: Context) -> None: + """Assert opening times with two breaks are updated in DoS. + + Args: + context (Context): The context object. + """ + context.service_id = get_service_id(context.change_event["ODSCode"]) + wait_for_service_update(context.service_id) + current_standard_openings = get_change_event_standard_opening_times(context.service_id) + assert "Monday" in current_standard_openings, "DoS not updated with standard opening time" + assert current_standard_openings["Monday"][0]["start_time"] == "09:00" + assert current_standard_openings["Monday"][0]["end_time"] == "12:00" + assert current_standard_openings["Monday"][1]["start_time"] == "12:30" + assert current_standard_openings["Monday"][1]["end_time"] == "16:00" + assert current_standard_openings["Monday"][2]["start_time"] == "16:10" + assert current_standard_openings["Monday"][2]["end_time"] == "16:30" + assert len(current_standard_openings["Monday"]) == 3, "Expected 3 opening times" + + +@then(parse('DoS is open on "{date}"'), target_fixture="context") +def the_changed_opening_time_is_accepted_by_dos(context: Context, date: str) -> Context: + """Assert the changed opening time is accepted by DoS. + + Args: + context (Context): The context object. + date (str): The date. + + Returns: + Context: The context object. + """ + wait_for_service_update(context.generator_data["id"]) + current_specified_openings = get_change_event_specified_opening_times(context.generator_data["id"]) + expected_opening_date = dt.strptime(date, "%b %d %Y").strftime("%Y-%m-%d") + assert expected_opening_date in current_specified_openings, "DoS not updated with specified opening time" + assert current_specified_openings[expected_opening_date] != [], "Date is not open in DoS" + return context + + +@then(parse('DoS is open from "{open_time}" until "{close_time}" on "{date}"'), target_fixture="context") +def the_changed_opening_time_is_accepted_by_dos_specific( + context: Context, + date: str, + open_time: str, + close_time: str, +) -> Context: + """Assert the changed opening time is accepted by DoS. + + Args: + context (Context): The context object. + date (str): The date. + open_time (str): The open time. + close_time (str): The close time. + + Returns: + Context: The context object. + """ + wait_for_service_update(context.generator_data["id"]) + current_specified_openings = get_change_event_specified_opening_times(context.generator_data["id"]) + expected_opening_date = dt.strptime(date, "%b %d %Y").strftime("%Y-%m-%d") + assert expected_opening_date in current_specified_openings, "DoS not updated with specified opening time" + assert ( + current_specified_openings[expected_opening_date][0]["start_time"] == open_time + ), "Date is not open at correct times" + assert ( + current_specified_openings[expected_opening_date][0]["end_time"] == close_time + ), "Date is not closed at correct times" + return context + + +@then(parse('DoS is closed on "{date}"'), target_fixture="context") +def the_changed_closing_time_is_accepted_by_dos(context: Context, date: str) -> Context: + """Assert the changed closing time is accepted by DoS. + + Args: + context (Context): The context object. + date (str): The date. + + Returns: + Context: The context object. + """ + wait_for_service_update(context.generator_data["id"]) + current_specified_openings = get_change_event_specified_opening_times(context.generator_data["id"]) + expected_opening_date = dt.strptime(date, "%b %d %Y").strftime("%Y-%m-%d") + assert expected_opening_date in current_specified_openings, "DoS not updated with specified opening time" + assert current_specified_openings[expected_opening_date] == [], "Date is not closed in DoS" + return context + + +@then(parse('there is no longer a specified opening on "{date}"'), target_fixture="context") +def specified_date_is_removed_from_dos(context: Context, date: str) -> Context: + """Assert specified date is removed from DoS. + + Args: + context (Context): The context object. + date (str): The date. + + Returns: + Context: The context object. + """ + sleep(60) + current_specified_openings = get_change_event_specified_opening_times(context.generator_data["id"]) + expected_opening_date = dt.strptime(date, "%b %d %Y").strftime("%Y-%m-%d") + assert ( + expected_opening_date not in current_specified_openings + ), f"Specified date {expected_opening_date} not removed from DoS" + return context + + +@then(parse('the pharmacy is confirmed "{open_or_closed}" on "{day}"'), target_fixture="context") +def standard_day_confirmed_open_check(context: Context, open_or_closed: str, day: str) -> Context: + """Assert the pharmacy is confirmed open or closed on a standard day. + + Args: + context (Context): The context object. + open_or_closed (str): The open or closed status. + day (str): The day. + + Returns: + Context: The context object. + """ + context.service_id = context.generator_data["id"] + sleep(60) + opening_time_event = get_change_event_standard_opening_times(context.service_id) + match open_or_closed.upper(): + case "CLOSED": + assert opening_time_event[day] == [], f'ERROR!.. Pharmacy is CLOSED but expected to be OPEN for "{day}"' + case "OPEN": + assert opening_time_event[day] != [], f'ERROR!.. Pharmacy is OPEN but expected to be CLOSED for "{day}"' + case _: + msg = f'Invalid status input parameter: "{open_or_closed}"' + raise ValueError(msg) + return context + + +@then(parse('the "{lambda_name}" lambda shows field "{field}" with value "{message}"')) +def generic_lambda_log_check_function(context: Context, lambda_name: str, field: str, message: str) -> None: + """Assert the lambda log contains the expected message. + + Args: + context (Context): The context object. + lambda_name (str): The lambda name. + field (str): The field. + message (str): The message. + """ + if "/" in context.correlation_id: + context.correlation_id = context.correlation_id.replace("/", r"\/") + query = ( + f"fields {field} | sort @timestamp asc" + f' | filter correlation_id="{context.correlation_id}" | filter {field} like "{message}"' + ) + logs = get_logs(query, lambda_name, context.start_time) + assert message in logs, f"ERROR!!.. error event processor did not detect the {field}: {message}." + + +@then(parse('the "{lambda_name}" lambda shows "{count}" of "{field}" with value "{message}"')) +def generic_lambda_multiple_log_check_function( + context: Context, + lambda_name: str, + count: str, + field: str, + message: str, +) -> None: + """Assert the lambda log contains the expected message. + + Args: + context (Context): The context object. + lambda_name (str): The lambda name. + count (str): The count. + field (str): The field. + message (str): The message. + """ + if "/" in context.correlation_id: + context.correlation_id = context.correlation_id.replace("/", r"\/") + query = ( + f"fields {field} | sort @timestamp asc" + f' | filter correlation_id="{context.correlation_id}" | filter {field} like "{message}"' + ) + logs = get_logs(query, lambda_name, context.start_time) + assert message in logs, f"ERROR!!.. error event processor did not detect the {field}: {message}." + assert len(loads(logs)["results"]) == int(count), "ERROR!!.. Incorrect number of log entries" + + +@then(parse('the "{lambda_name}" lambda does not show "{field}" with value "{message}"')) +def generic_lambda_log_negative_check_function(context: Context, lambda_name: str, field: str, message: str) -> None: + """Assert the lambda log does not contain the expected message. + + Args: + context (Context): The context object. + lambda_name (str): The lambda name. + field (str): The field. + message (str): The message. + """ + find_request_id_query = ( + f'fields function_request_id | sort @timestamp asc | filter correlation_id="{context.correlation_id}"' + ) + find_request_id = loads(get_logs(find_request_id_query, lambda_name, context.start_time)) + + request_id = "" + for x in find_request_id["results"][0]: + if x["field"] == "function_request_id": + request_id = x["value"] + + finished_check = f'fields @message | filter @requestId == "{request_id}" | filter @type == "END"' + + get_logs(finished_check, lambda_name, context.start_time, 2) + + query = ( + f"fields {field} | sort @timestamp asc" + f' | filter correlation_id="{context.correlation_id}" | filter {field} like "{message}"' + ) + logs_found = negative_log_check(query, lambda_name, context.start_time) + + assert logs_found is True, f"ERROR!!.. error event processor did not detect the {field}: {message}." + + +@then(parse('the Slack channel shows an alert saying "{message}" from "{environment_type}"')) +def slack_message_check(message: str, environment_type: str) -> None: + """Assert the slack channel contains the expected message. + + Args: + message (str): The message. + environment_type (str): The environment type. + """ + slack_entries = slack_retry(message) + current_environment = getenv(environment_type) + assert_string = f"{current_environment} | {message}" + assert assert_string in slack_entries + + +@then("the service table has been updated with locations data") +def services_location_update_assertion(context: Context) -> None: + """Assert the service table has been updated with locations data. + + Args: + context (Context): The context object. + """ + sleep(20) + location_data = get_locations_table_data(context.change_event["Postcode"]) + services_data = get_services_table_location_data(context.service_id) + assert services_data == location_data, "ERROR: Services and Location data does not match" + + +@then("the service history table has been updated with locations data") +def services_location_history_update_assertion(context: Context) -> None: + """Assert the service history table has been updated with locations data. + + Args: + context (Context): The context object. + """ + sleep(10) + history_data = get_service_history(context.service_id) + history_data = history_data[next(iter(history_data.keys()))]["new"] + history_list = [ + history_data["cmsorgtown"]["data"], + history_data["postalcode"]["data"], + history_data["cmseastings"]["data"], + history_data["cmsnorthings"]["data"], + ] + location_data = get_locations_table_data(context.change_event["Postcode"]) + location_data = list(location_data[0].values())[:-2] + assert history_list == location_data, "ERROR: Service History and Location data does not match" + + +@then("the s3 bucket contains an email file matching the service uid") +def check_s3_contains_email_file(context: Context) -> None: + """Assert the s3 bucket contains an email file matching the service uid. + + Args: + context (Context): The context object. + """ + get_s3_email_file(context) + assert context.service_uid in context.other["email_body"], "ERROR: service_uid not found in email body" + + +@then("the changes table shows change is now rejected") +def check_changes_table_has_been_updated(context: Context) -> None: + """Assert the changes table shows change is now rejected. + + Args: + context (Context): The context object. + """ + status = check_pending_service_is_rejected(context.service_id) + assert "REJECTED" in status, "ERROR: changes table has not been updated" + + +@then("service sync log contains no overlapping log data", target_fixture="context") +def show_service_sync_logs(context: Context) -> None: + """Assert the service sync log contains no overlapping log data. + + Args: + context (Context): The context object. + """ + query = ( + f'fields @message | sort @timestamp asc | filter correlation_id="{context.correlation_id}"' + '|filter message like "Attempting connection to database"' + ) + logs = loads(get_logs(query, "service-sync", context.start_time))["results"][0][0]["value"] + assert "service_name" not in logs, "ERROR: service name found in logs" + assert "service_uid" not in logs, "ERROR: service uid found in logs" + + +@then("logs show staff data has been redacted", target_fixture="context") +def ingest_staff_redaction(context: Context) -> Context: + """Assert the logs show staff data has been redacted. + + Args: + context (Context): The context object. + """ + query = 'fields @message | sort @timestamp asc | filter message like "key from Change Event payload"' + logs = loads(get_logs(query, "ingest-change-event", context.start_time)) + assert logs != [], "ERROR: Logs do not show redaction of staff field" + return context + + +@then("error messages do not show Staff data", target_fixture="context") +def error_contains_no_staff(context: Context) -> Context: + """Assert the error messages do not show Staff data. + + Args: + context (Context): The context object. + """ + query = ( + f'fields @event | sort @timestamp asc | filter correlation_id="{context.correlation_id}"' + '|filter message like "Validation Error - Unexpected Org Sub Type ID"' + ) + logs = loads(get_logs(query, "ingest-change-event", context.start_time)) + assert "Superintendent Pharmacist" not in logs, "ERROR: Logs output the staff field on error" + return context + + +@then(parse('palliative care is "{action}" to the service'), target_fixture="context") +def _(context: Context, action: str) -> Context: + """Assert palliative care is applied to the service. + + Args: + context (Context): The context object. + action (str): The action. + + Returns: + Context: The context object. + """ + match action: + case "added": + applied = True + palliative_care = get_palliative_care(context.service_id) + case "removed": + applied = False + palliative_care = get_palliative_care(context.service_id) + case "applied": + applied = True + palliative_care = get_palliative_care(context.service_id, wait_for_update=False) + case "not applied": + applied = False + palliative_care = get_palliative_care(context.service_id, wait_for_update=False) + case _: + msg = f"Unexpected action: {action}" + raise ValueError(msg) + + assert palliative_care == applied, "ERROR: Palliative care not correctly applied/removed to DoS service" + return context + + +@then(parse("blood pressure Z Code is added to the service"), target_fixture="context") +def _(context: Context) -> Context: + """Assert the error messages do not show Staff data. + + Args: + context (Context): The context object. + action (str): The action. + + Returns: + Context: The context object. + """ + blood_pressure = get_blood_pressure_sgsd(context.service_id) + assert blood_pressure is True, "ERROR Blood Pressure not correctly applied to DoS service" + return context + + +@then(parse("contraception Z Code is added to the service"), target_fixture="context") +def _(context: Context) -> Context: + """Assert the error messages do not show Staff data. + + Args: + context (Context): The context object. + action (str): The action. + + Returns: + Context: The context object. + """ + contraception = get_contraception_sgsd(context.service_id) + assert contraception is True, "ERROR Contraception not correctly applied to DoS service" + return context + + +@then( + parse("Hidden or Closed logs does not show closed services or not going to active services"), + target_fixture="context", +) +def _(context: Context) -> Context: + logs = get_logs( + query=f"fields @message | filter report_key == 'HIDDEN_OR_CLOSED' | filter correlation_id == '{context.correlation_id}' | sort @timestamp", # noqa: E501 + lambda_name="service-matcher", + start_time=context.start_time, + ) + results = loads(logs)["results"] + value = loads(results[0][0]["value"]) + count = [result["value"] for result in results[0] if result["field"] == "@message"] + assert value["dos_service_typeid"] == 13, "ERROR: Incorrect service type id found" + assert len(count) == 1, "ERROR: More than one log entry found" + return context + + +@given( + parse( + "{service_count:d} {service_status} services of type {service_type:d} for an odscode starting with {starting_character}", # noqa: E501 + ), + target_fixture="context", +) +def _(context: Context, service_count: int, service_status: str, service_type: int, starting_character: str) -> Context: + """Create number of services of a given type and status for an odscode starting with a specific starting character. + + Args: + context (Context): The context object. + service_count (int): The number of services. + service_status (str): The service status. + service_type (int): The service type. + starting_character (str): The starting character. + + Returns: + Context: The context object. + """ + odscode = f"{starting_character.upper()}{randint(1000, 9999)}" + context.ods_code = odscode + for _ in range(service_count): + context = a_service_table_entry_is_created(context, odscode) + context.generator_data["service_status"] = get_status_id(service_status) + context.generator_data["service_type"] = service_type + context = service_table_entry_is_committed(context) + return context + + +@given( + parse( + "an active service of type {service_type:d} for a {character_count:d} character odscode starting with {starting_character}", # noqa: E501 + ), + target_fixture="context", +) +def _(context: Context, service_type: int, character_count: int, starting_character: str) -> Context: + """Create an active service of given type for a defined length odscode starting with a specific starting character. + + Args: + context (Context): The context object. + service_type (int): The service type. + character_count (int): The character count. + starting_character (str): The starting character. + + Returns: + Context: The context object. + """ + min_value = f"{'0'* (character_count-2)} " + max_value = "9" * (character_count - 1) + odscode = f"{starting_character}{randint(int(min_value), int(max_value))}" + context.ods_code = odscode + context = a_service_table_entry_is_created(context, odscode) + context.generator_data["service_type"] = service_type + return service_table_entry_is_committed(context) + + +@given(parse("the DoS service has {commissioned_service} Z code"), target_fixture="context") +def _(context: Context, commissioned_service: str) -> Context: + match commissioned_service.lower(): + case "blood pressure": + apply_blood_pressure_to_service(context) + case "contraception": + apply_contraception_to_service(context) + case "palliative care": + apply_palliative_care_to_service(context) + case _: + msg = f"Unexpected commissioned service: {commissioned_service}" + raise ValueError(msg) + return context + + +@when("the quality checker is run", target_fixture="context") +def _(context: Context) -> Context: + """Run the quality checker. + + Args: + context (Context): The context object. + + Returns: + Context: The context object. + """ + context.start_time = dt.now(tz=timezone("Europe/London")).timestamp() + context.response = invoke_quality_checker_lambda() + return context + + +@then(parse("the following {reason} is reported {reason_count:d} times"), target_fixture="context") +def _(context: Context, reason: str, reason_count: int) -> Context: + """Assert the quality checker reports the expected reason. + + Args: + context (Context): The context object. + reason (str): The reason in the report. + reason_count (int): The number of times the reason is reported. + + Returns: + Context: The context object. + """ + logs = quality_checker_log_check( + request_id=context.response["ResponseMetadata"]["RequestId"], + odscode=context.ods_code or context.generator_data["odscode"], + reason=reason, + start_time=context.start_time, + ) + logs = [log[0]["value"] for log in logs if log[0]["field"] == "message"] + assert len(logs) == reason_count, f"ERROR: Expected {reason_count} {reason} logs, found {len(logs)}" + return context + + +@then(parse("the following {reason} is reported {reason_count:d} times with a long odscode"), target_fixture="context") +def _(context: Context, reason: str, reason_count: int) -> Context: + """Assert the quality checker reports the expected reason. + + Args: + context (Context): The context object. + reason (str): The reason in the report. + reason_count (int): The number of times the reason is reported. + + Returns: + Context: The context object. + """ + logs = quality_checker_log_check( + request_id=context.response["ResponseMetadata"]["RequestId"], + odscode=context.ods_code or context.generator_data["odscode"], + reason=reason, + start_time=context.start_time, + match_on_more_than_5_character_odscode=True, + ) + logs = [log[0]["value"] for log in logs if log[0]["field"] == "message"] + assert len(logs) == reason_count, f"ERROR: Expected {reason_count} {reason} logs, found {len(logs)}" + return context + + +@then(parse("the following {reason} is not reported"), target_fixture="context") +def _(context: Context, reason: str) -> Context: + """Assert the quality checker reports the expected reason. + + Args: + context (Context): The context object. + reason (str): The reason in the report. + + Returns: + Context: The context object. + """ + assert ( + quality_checker_negative_log_check( + request_id=context.response["ResponseMetadata"]["RequestId"], + odscode=context.ods_code or context.generator_data["odscode"], + reason=reason, + start_time=context.start_time, + ) + is True + ), f"ERROR: {reason} logs found" + return context diff --git a/test/integration/steps/utilities/aws.py b/test/integration/steps/utilities/aws.py deleted file mode 100644 index 855941f41..000000000 --- a/test/integration/steps/utilities/aws.py +++ /dev/null @@ -1,130 +0,0 @@ -from datetime import datetime, timedelta -from os import getenv as get_env -from sqlite3 import Timestamp -from time import sleep -from boto3 import client -from json import dumps -import json -from json.decoder import JSONDecodeError - -LAMBDA_CLIENT_LOGS = client("logs") -EVENT_PROCESSOR = get_env("EVENT_PROCESSOR") -EVENT_SENDER = get_env("EVENT_SENDER") -CR_FIFO_DLQ = get_env("CR_FIFO_DLQ") -LOG_GROUP_NAME_EVENT_PROCESSOR = f"/aws/lambda/{EVENT_PROCESSOR}" -LOG_GROUP_NAME_EVENT_SENDER = f"/aws/lambda/{EVENT_SENDER}" -LOG_GROUP_NAME_CR_FIFO_DLQ = f"/aws/lambda/{CR_FIFO_DLQ}" - - -def get_processor_log_stream_name() -> str: - log_stream = LAMBDA_CLIENT_LOGS.describe_log_streams( - logGroupName=LOG_GROUP_NAME_EVENT_PROCESSOR, - orderBy="LastEventTime", - descending=True, - ) - return log_stream["logStreams"][0]["logStreamName"] - - -def get_sender_log_stream_name() -> str: - log_stream = LAMBDA_CLIENT_LOGS.describe_log_streams( - logGroupName=LOG_GROUP_NAME_EVENT_SENDER, - orderBy="LastEventTime", - descending=True, - ) - return log_stream["logStreams"][0]["logStreamName"] - - -def get_logs(query: str, event_lambda: str, start_time: Timestamp, retrycount=32) -> str: - log_groups = { - "processor": LOG_GROUP_NAME_EVENT_PROCESSOR, - "sender": LOG_GROUP_NAME_EVENT_SENDER, - "cr_dlq": LOG_GROUP_NAME_CR_FIFO_DLQ, - } - if event_lambda == "processor" or "sender" or "cr_dlq": - log_group_name = log_groups[event_lambda] - else: - raise Exception("Error.. log group name not correctly specified") - logs_found = False - counter = 0 - while logs_found is False: - start_query_response = LAMBDA_CLIENT_LOGS.start_query( - logGroupName=log_group_name, - startTime=int(start_time), - endTime=int(datetime.now().timestamp()), - queryString=query, - ) - query_id = start_query_response["queryId"] - response = None - while response is None or response["status"] != "Complete": - sleep(20) - response = LAMBDA_CLIENT_LOGS.get_query_results(queryId=query_id) - counter += 1 - if response["results"] != []: - logs_found = True - elif counter == retrycount: - raise Exception("Log search retries exceeded.. no logs found") - return dumps(response, indent=2) - - -def negative_log_check(query: str, event_lambda: str, start_time: Timestamp) -> str: - log_groups = { - "processor": LOG_GROUP_NAME_EVENT_PROCESSOR, - "sender": LOG_GROUP_NAME_EVENT_SENDER, - "cr_dlq": LOG_GROUP_NAME_CR_FIFO_DLQ, - } - if event_lambda == "processor" or "sender" or "cr_dlq": - log_group_name = log_groups[event_lambda] - else: - raise Exception("Error.. log group name not correctly specified") - - start_query_response = LAMBDA_CLIENT_LOGS.start_query( - logGroupName=log_group_name, - startTime=int(start_time), - endTime=int(datetime.now().timestamp()), - queryString=query, - ) - - query_id = start_query_response["queryId"] - sleep(30) - response = LAMBDA_CLIENT_LOGS.get_query_results(queryId=query_id) - - if response["results"] == []: - return True - else: - raise Exception("Matching logs have been found") - - -def get_processor_logs_list_for_debug(seconds_ago: int = 0) -> list: - - """Work out timestamps""" - now = datetime.utcnow() - past = now - timedelta(seconds=seconds_ago) - - # Get log events - event_log = LAMBDA_CLIENT_LOGS.get_log_events( - logGroupName=LOG_GROUP_NAME_EVENT_PROCESSOR, - logStreamName=get_processor_log_stream_name(), - startTime=int(past.timestamp() * 1000), - endTime=int(now.timestamp() * 1000), - ) - # If a message is a JSON string, format the string before returning. - messages = [] - for event in event_log["events"]: - try: - messages.append(json.dumps(json.loads(event["message"]), indent=2)) - except JSONDecodeError: - messages.append(event["message"]) - - return messages - - -def get_processor_logs_within_time_frame_for_debug(time_in_seconds: int = 0) -> dict: - logs = get_processor_logs_list_for_debug(time_in_seconds) - for m in logs: - print(m) - - -def get_secret(secret_name: str) -> str: - secrets_manager = client(service_name="secretsmanager") - get_secret_value_response = secrets_manager.get_secret_value(SecretId=secret_name) - return get_secret_value_response["SecretString"] diff --git a/test/integration/steps/utilities/constants.py b/test/integration/steps/utilities/constants.py deleted file mode 100644 index 1c018c346..000000000 --- a/test/integration/steps/utilities/constants.py +++ /dev/null @@ -1,18 +0,0 @@ -PHARMACY_ORG_TYPE_ID = "PHA" -DENTIST_ORG_TYPE_ID = "Dentist" -ORGANISATION_SUB_TYPES_KEY = "ORGANISATION_SUB_TYPES" -VALID_SERVICE_TYPES_KEY = "VALID_SERVICE_TYPES" -ODSCODE_LENGTH_KEY = "ODSCODE_LENGTH" - -SERVICE_TYPES = { - PHARMACY_ORG_TYPE_ID: { - ORGANISATION_SUB_TYPES_KEY: ["Community"], - VALID_SERVICE_TYPES_KEY: [13, 131, 132, 134, 137], - ODSCODE_LENGTH_KEY: 5, - }, - DENTIST_ORG_TYPE_ID: { - ORGANISATION_SUB_TYPES_KEY: ["TBA"], - VALID_SERVICE_TYPES_KEY: [12], - ODSCODE_LENGTH_KEY: 7, - }, -} diff --git a/test/integration/steps/utilities/events.py b/test/integration/steps/utilities/events.py deleted file mode 100644 index a72063389..000000000 --- a/test/integration/steps/utilities/events.py +++ /dev/null @@ -1,209 +0,0 @@ -import re -from datetime import datetime -from json import dumps, load -from random import choice -from typing import Any, Dict - -from dateutil.relativedelta import relativedelta - -from .utils import ( - get_change_event_demographics, - get_change_event_specified_opening_times, - get_change_event_standard_opening_times, - get_odscodes_list, - get_single_service_odscode, -) -from .constants import PHARMACY_ORG_TYPE_ID, DENTIST_ORG_TYPE_ID - -pharmacy_odscode_list = None -dentist_odscode_list = None - - -def create_change_event(service_type: str) -> Dict[str, Any]: - with open("resources/payloads/expected_schema.json", "r", encoding="utf-8") as json_file: - payload = load(json_file) - if service_type.upper() == "PHARMACY": - payload["ODSCode"] = random_pharmacy_odscode() - elif service_type.upper() == "DENTIST": - payload["ODSCode"] = random_dentist_odscode() - else: - raise ValueError(f"Service type {service_type} does not exist") - payload["OrganisationName"] = f'{payload["OrganisationName"]} {datetime.now()}' - print(payload["ODSCode"]) - return payload - - -def change_request() -> Dict[str, Any]: - return { - "change_payload": { - "reference": "EDFA07-16", - "system": "DoS Integration", - "message": "DoS Integration CR. correlation-id: EDFA07-16", - "service_id": "37652", - "changes": { - "website": None, - "phone": None, - "public_name": "My Test Pharmacy 21", - "address": { - "address_lines": ["85 Peachfield Road", "CHAPEL ROW", "South Godshire"], - "post_code": "RG7 1DB", - }, - "opening_days": { - "Monday": [], - "Tuesday": [], - "Wednesday": [], - "Thursday": [], - "Friday": [], - "Saturday": [], - "Sunday": [], - }, - }, - }, - "correlation_id": "c1", - "message_received": 1643306908893, - "dynamo_record_id": "d8842511670361f8db0f52d5ab86e78c", - "ods_code": "FA007", - } - - -def random_pharmacy_odscode() -> str: - global pharmacy_odscode_list - if pharmacy_odscode_list is None: - lambda_payload = {"type": "get_pharmacy_odscodes"} - pharmacy_odscode_list = get_odscodes_list(lambda_payload) - return choice(pharmacy_odscode_list)[0] - - -def random_dentist_odscode() -> str: - global dentist_odscode_list - if dentist_odscode_list is None: - lambda_payload = {"type": "get_dentist_odscodes"} - dentist_odscode_list = get_odscodes_list(lambda_payload) - odscode = choice(dentist_odscode_list)[0] - return f"{odscode[0]}0{odscode[1:]}" - - -def get_payload(payload_name: str) -> str: - values = {"valid": "expected_schema.json", "invalid": "invalid_payload.json"} - if payload_name in ["valid", "invalid"]: - payload_file_name = values[payload_name] - else: - raise Exception("Unable to find Payload by request name") - with open(f"./features/resources/payloads/{payload_file_name}", "r", encoding="utf-8") as json_file: - return dumps(load(json_file)) - - -def build_same_as_dos_change_event(service_type: str): - ods_code = get_single_service_odscode() - change_event = build_same_as_dos_change_event_by_ods(service_type, ods_code) - if valid_change_event(change_event): - return change_event - else: - return build_same_as_dos_change_event(service_type) - - -def build_same_as_dos_change_event_by_ods(service_type: str, ods_code: str): - # TODO Refactor into change event class - change_event = create_change_event(service_type) - if service_type.upper() == "PHARMACY": - change_event["ODSCode"] = ods_code - demographics_data = get_change_event_demographics(change_event["ODSCode"], PHARMACY_ORG_TYPE_ID) - elif service_type.upper() == "DENTIST": - demographics_data = get_change_event_demographics(change_event["ODSCode"], DENTIST_ORG_TYPE_ID) - else: - raise ValueError(f"Service type {service_type} does not exist") - print(f"Latest selected ODSCode: {change_event['ODSCode']}") - change_event["OrganisationName"] = demographics_data["publicname"] - change_event["Postcode"] = demographics_data["postcode"] - change_event["Contacts"][0]["ContactValue"] = demographics_data["web"] - change_event["Contacts"][1]["ContactValue"] = demographics_data["publicphone"] - address_keys = ["Address1", "Address2", "Address3", "City", "County"] - for address_key in address_keys: - change_event[address_key] = None - address_parts = demographics_data["address"].split("$", 4) - counter = 0 - for address_part in address_parts: - change_event[address_keys[counter]] = address_part - counter += 1 - standard_opening_times = get_change_event_standard_opening_times(demographics_data["id"]) - change_event["OpeningTimes"] = [] - for day in standard_opening_times: - for opening_times in standard_opening_times[day]: - change_event["OpeningTimes"].append( - { - "Weekday": day, - "OpeningTime": opening_times["start_time"], - "ClosingTime": opening_times["end_time"], - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": True, - } - ) - specified_opening_times = get_change_event_specified_opening_times(demographics_data["id"]) - for date in specified_opening_times: - for opening_times in specified_opening_times[date]: - str_date = datetime.strptime(date, "%Y-%m-%d") - change_event["OpeningTimes"].append( - { - "Weekday": "", - "OpeningTime": opening_times["start_time"], - "ClosingTime": opening_times["end_time"], - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": str_date.strftime("%b %d %Y"), - "IsOpen": True, - } - ) - return change_event - - -def valid_change_event(change_event: dict) -> bool: - """This function checks if the data stored in DoS would pass the change request - validation within DoS API Gateway""" - if not re.fullmatch( - r"(https?:\/\/)?([a-z\d][a-z\d-]*[a-z\d]\.)+[a-z]{2,}(\/.*)?", str(change_event["Contacts"][0]["ContactValue"]) - ): # Website - return False - if not re.fullmatch(r"[+0][0-9 ()]{9,}", str(change_event["Contacts"][1]["ContactValue"])): # Phone - return False - return True - - -def set_opening_times_change_event(service_type: str): - change_event = build_same_as_dos_change_event(service_type) - date = datetime.today() + relativedelta(months=1) - has_set_closed_day = False - for day in change_event["OpeningTimes"]: - if day["IsOpen"] and day["OpeningTimeType"] == "General": - closed_day = day["Weekday"] - has_set_closed_day = True - break - if has_set_closed_day is False: - raise ValueError("ERROR!.. Unable to find 'Open' Standard opening time") - change_event["OpeningTimes"] = list(filter(lambda day: day["Weekday"] != closed_day, change_event["OpeningTimes"])) - change_event["OpeningTimes"].append( - { - "Weekday": closed_day, - "OpeningTime": "", - "ClosingTime": "", - "Times": "-", - "OffsetOpeningTime": 0, - "OffsetClosingTime": 0, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": False, - } - ) - change_event["OpeningTimes"].append( - { - "Weekday": "", - "OpeningTime": "", - "ClosingTime": "", - "Times": "-", - "OffsetOpeningTime": 0, - "OffsetClosingTime": 0, - "OpeningTimeType": "Additional", - "AdditionalOpeningDate": date.strftime("%b %d %Y"), - "IsOpen": False, - } - ) - return change_event diff --git a/test/integration/steps/utilities/utils.py b/test/integration/steps/utilities/utils.py deleted file mode 100644 index ffe947817..000000000 --- a/test/integration/steps/utilities/utils.py +++ /dev/null @@ -1,393 +0,0 @@ -import random -from ast import literal_eval -from datetime import datetime -from decimal import Decimal -from json import dumps, loads -from os import getenv -from random import choice -from time import sleep, time_ns -from typing import Any, Dict - -import requests -from boto3 import client -from boto3.dynamodb.types import TypeDeserializer -from requests import Response - -from .aws import get_secret -from .constants import SERVICE_TYPES - -URL = getenv("URL") -CR_URL = getenv("CR_URL") -SQS_URL = getenv("SQS_URL") -EVENT_PROCESSOR = getenv("EVENT_PROCESSOR") -DYNAMO_DB_TABLE = getenv("DYNAMO_DB_TABLE") -LAMBDA_CLIENT_FUNCTIONS = client("lambda") -SQS_CLIENT = client("sqs") -DYNAMO_CLIENT = client("dynamodb") -RDS_DB_CLIENT = client("rds") - - -def process_payload(payload: dict, valid_api_key: bool, correlation_id: str) -> Response: - api_key = "invalid" - if valid_api_key: - api_key = loads(get_secret(getenv("API_KEY_SECRET")))[getenv("NHS_UK_API_KEY")] - sequence_number = str(time_ns()) - headers = { - "x-api-key": api_key, - "sequence-number": sequence_number, - "correlation-id": correlation_id, - "Content-Type": "application/json", - } - payload["Unique_key"] = generate_random_int() - output = requests.request("POST", URL, headers=headers, data=dumps(payload)) - return output - - -def process_payload_with_sequence(payload: dict, correlation_id: str, sequence_id) -> Response: - api_key = loads(get_secret(getenv("API_KEY_SECRET")))[getenv("NHS_UK_API_KEY")] - headers = { - "x-api-key": api_key, - "correlation-id": correlation_id, - "Content-Type": "application/json", - } - if sequence_id is not None: - headers["sequence-number"] = str(sequence_id) - payload["Unique_key"] = generate_random_int() - output = requests.request("POST", URL, headers=headers, data=dumps(payload)) - return output - - -def process_change_request_payload(payload: dict, api_key_valid: bool) -> Response: - api_key = "invalid" - if api_key_valid: - secret = loads(get_secret(getenv("CR_API_KEY_SECRET"))) - api_key = secret[getenv("CR_API_KEY_KEY")] - headers = { - "x-api-key": api_key, - "Content-Type": "application/json", - } - output = requests.request("POST", CR_URL, headers=headers, data=dumps(payload)) - return output - - -def get_stored_events_from_dynamo_db(odscode: str, sequence_number: Decimal) -> dict: - print(f"{DYNAMO_DB_TABLE} {odscode} {sequence_number}") - resp = DYNAMO_CLIENT.query( - TableName=DYNAMO_DB_TABLE, - IndexName="gsi_ods_sequence", - ProjectionExpression="ODSCode,SequenceNumber", - ExpressionAttributeValues={ - ":v1": { - "S": odscode, - }, - ":v2": { - "N": str(sequence_number), - }, - }, - KeyConditionExpression="ODSCode = :v1 and SequenceNumber = :v2 ", - Limit=1, - ScanIndexForward=False, - ) - item = resp["Items"][0] - deserializer = TypeDeserializer() - deserialized = {k: deserializer.deserialize(v) for k, v in item.items()} - return deserialized - - -def get_lambda_info(info_param: str) -> str: - values = {"state": "State", "status": "LastUpdateStatus", "description": "Description"} - param = values[info_param] - response = LAMBDA_CLIENT_FUNCTIONS.get_function(FunctionName=EVENT_PROCESSOR) - return response["Configuration"][param] - - -def get_latest_sequence_id_for_a_given_odscode(odscode: str) -> int: - """Get latest sequence id for a given odscode from dynamodb""" - try: - resp = DYNAMO_CLIENT.query( - TableName=DYNAMO_DB_TABLE, - IndexName="gsi_ods_sequence", - KeyConditionExpression="ODSCode = :odscode", - ExpressionAttributeValues={ - ":odscode": {"S": odscode}, - }, - Limit=1, - ScanIndexForward=False, - ProjectionExpression="ODSCode,SequenceNumber", - ) - sequence_number = 0 - if resp.get("Count") > 0: - sequence_number = int(resp.get("Items")[0]["SequenceNumber"]["N"]) - except Exception as err: - print(f"Unable to get sequence id from dynamodb for a given ODSCode {odscode} {DYNAMO_DB_TABLE} .Error: {err}") - raise - return sequence_number - - -def generate_unique_sequence_number(odscode: str) -> str: - return str(get_latest_sequence_id_for_a_given_odscode(odscode) + 1) - - -def generate_random_int() -> str: - return str(random.sample(range(1000), 1)[0]) - - -def get_odscodes_list(lambda_payload: dict) -> list[list[str]]: - response = invoke_test_db_checker_handler_lambda(lambda_payload) - data = loads(response) - data = literal_eval(data) - return data - - -def get_single_service_odscode() -> str: - lambda_payload = {"type": "get_single_pharmacy_service_odscode"} - response = invoke_test_db_checker_handler_lambda(lambda_payload) - data = loads(response) - data = literal_eval(data) - odscode = choice(data)[0] - return odscode - - -def get_changes(correlation_id: str) -> list: - lambda_payload = {"type": "get_changes", "correlation_id": correlation_id} - response = invoke_test_db_checker_handler_lambda(lambda_payload) - data = loads(loads(response)) - return data - - -def confirm_changes(correlation_id: str) -> list: - changes_loop_count = 0 - data = [] - while changes_loop_count < 12: - sleep(10) - data = get_changes(correlation_id) - if data != []: - break - changes_loop_count += 1 - print(f"Number of confirm-changes retries: {changes_loop_count}") - return data - - -def get_approver_status(correlation_id: str) -> list: - lambda_payload = {"type": "get_approver_status", "correlation_id": correlation_id} - response = invoke_test_db_checker_handler_lambda(lambda_payload) - data = loads(loads(response)) - return data - - -def confirm_approver_status(correlation_id: str) -> list: - approver_loop_count = 0 - data = [] - while approver_loop_count < 15: - sleep(60) - data = get_approver_status(correlation_id) - if data != []: - break - approver_loop_count += 1 - print(f"Number of approver retries: {approver_loop_count}") - return data - - -def get_service_id(correlation_id: str) -> list: - retries = 0 - data = [] - data_status = False - while data_status is False: - lambda_payload = {"type": "get_service_id", "correlation_id": correlation_id} - response = invoke_test_db_checker_handler_lambda(lambda_payload) - data = loads(response) - data = literal_eval(data) - if data != []: - print(f"Number of service_id retries: {retries}") - print(data) - return data[0][0] - - if retries > 8: - raise Exception("Error!.. Service Id not found") - retries += 1 - sleep(5) - - -def get_service_type_from_cr(correlation_id: str) -> list: - retries = 0 - data = [] - data_status = False - while data_status is False: - lambda_payload = {"type": "get_service_type_from_cr", "get_service_id": get_service_id(correlation_id)} - response = invoke_test_db_checker_handler_lambda(lambda_payload) - data = loads(response) - data = literal_eval(data) - if data != []: - print(f"Number of service_type retries: {retries}") - print(data) - return data[0][0] - - if retries > 8: - raise Exception("Error!.. Service type not found") - retries += 1 - sleep(5) - - -def get_service_type_data(organisation_type_id: str) -> list[int]: - """Get the valid service types for the organisation type id""" - return SERVICE_TYPES[organisation_type_id] - - -def get_change_event_demographics(odscode: str, organisation_type_id: str) -> Dict[str, Any]: - lambda_payload = { - "type": "change_event_demographics", - "odscode": odscode, - "organisation_type_id": organisation_type_id, - } - response = invoke_test_db_checker_handler_lambda(lambda_payload) - data = loads(loads(response)) - return data - - -def get_change_event_standard_opening_times(service_id: str) -> Any: - lambda_payload = {"type": "change_event_standard_opening_times", "service_id": service_id} - response = invoke_test_db_checker_handler_lambda(lambda_payload) - data = loads(response) - data = literal_eval(data) - return data - - -def get_change_event_specified_opening_times(service_id: str) -> Any: - lambda_payload = {"type": "change_event_specified_opening_times", "service_id": service_id} - response = invoke_test_db_checker_handler_lambda(lambda_payload) - data = loads(response) - data = literal_eval(data) - return data - - -def invoke_test_db_checker_handler_lambda(lambda_payload: dict) -> Any: - response_status = False - response = None - retries = 0 - while response_status is False: - response: Any = LAMBDA_CLIENT_FUNCTIONS.invoke( - FunctionName=getenv("TEST_DB_CHECKER_FUNCTION_NAME"), - InvocationType="RequestResponse", - Payload=dumps(lambda_payload), - ) - response_payload = response["Payload"].read().decode("utf-8") - if "errorMessage" not in response_payload: - return response_payload - - if retries > 9: - print(f"Errored on this payload: {lambda_payload}") - raise Exception(f"Unable to run test db checker lambda successfully after {retries} retries") - retries += 1 - sleep(20) - - -def check_received_data_in_dos(corr_id: str, search_key: str, search_param: str): - """NOT COMPATIBLE WITH OPENING TIMES CHANGES""" - response = confirm_changes(corr_id) - if search_key not in str(response): - raise ValueError(f"{search_key} not found..") - for row in response: - change_value = dict(loads(row[0])) - for dos_change_key in change_value["new"]: - if dos_change_key == search_key and search_param in change_value["new"][dos_change_key]["data"]: - return True - raise ValueError(f"{search_param} not found in Dos changes... {response}") - - -def check_specified_received_opening_times_date_in_dos(corr_id: str, search_key: str, search_param: str): - """ONLY COMPATIBLE WITH OPENING TIMES CHANGES""" - response = get_changes(corr_id) - if search_key not in str(response): - raise ValueError(f"{search_key} not found..") - row_found = False - for row in response: - for k in dict(loads(row[0]))["new"]: - if k == search_key: - if dict(loads(row[0]))["new"][k]["changetype"] != "delete": - date_in_dos = dict(loads(row[0]))["new"][k]["data"]["add"][0][:10] - # Convert and format 'search_param' to datetime type - date_in_payload = datetime.strptime(search_param, "%b %d %Y").strftime("%d-%m-%Y") - if date_in_dos == date_in_payload: - row_found = True - if row_found is True: - return True - else: - raise ValueError(f'Specified date change "{date_in_payload}" not found in Dos changes..') - - -def check_contact_delete_in_dos(corr_id: str, search_key: str): - response = get_changes(corr_id) - if search_key not in str(response): - raise ValueError(f"{search_key} not found..") - row_found = False - for row in response: - for k in dict(loads(row[0]))["new"]: - if k == search_key: - if dict(loads(row[0]))["new"][k]["changetype"] == "delete": - data = dict(loads(row[0]))["new"][k]["data"] - if data == "": - row_found = True - if row_found is True: - return True - else: - raise ValueError("Expected a 'delete' on the website but didn't find one") - - -def check_specified_received_opening_times_time_in_dos(corr_id: str, search_key: str, search_param: str): - """ONLY COMPATIBLE WITH OPENING TIMES CHANGES""" - response = get_changes(corr_id) - if search_key not in str(response): - raise ValueError(f"{search_key} not found..") - row_found = False - for row in response: - for k in dict(loads(row[0]))["new"]: - if k == search_key: - if dict(loads(row[0]))["new"][k]["changetype"] != "delete": - time_in_dos = dict(loads(row[0]))["new"][k]["data"]["add"][0][11:] - if time_in_dos == search_param: - row_found = True - if row_found is True: - return True - else: - raise ValueError("Specified Opening-time time change not found in Dos changes..") - - -def check_standard_received_opening_times_time_in_dos(corr_id: str, search_key: str, search_param: str): - """ONLY COMPATIBLE WITH OPENING TIMES CHANGES""" - response = get_changes(corr_id) - if search_key not in str(response): - raise ValueError(f"{search_key} not found..") - for row in response: - for k in dict(loads(row[0]))["new"]: - if k == search_key: - time_in_dos = dict(loads(row[0]))["new"][k]["data"]["add"][0] - if time_in_dos == search_param: - return True - else: - raise ValueError("Standard Opening-time time change not found in Dos changes... {response}") - - -def time_to_sec(t): - h, m = map(int, t.split(":")) - return (h * 3600) + (m * 60) - - -def generate_correlation_id(suffix=None) -> str: - name_no_space = getenv("PYTEST_CURRENT_TEST").split(":")[-1].split(" ")[0].replace(" ", "_") - run_id = getenv("RUN_ID") - correlation_id = f"{run_id}_{name_no_space}" if suffix is None else f"{run_id}_{suffix}" - correlation_id = ( - correlation_id if len(correlation_id) < 80 else correlation_id[:79] - ) # DoS API Gateway max reference is 100 characters - return correlation_id - - -def re_process_payload(odscode: str, seq_number: str) -> str: - lambda_payload = {"odscode": odscode, "sequence_number": seq_number} - response = LAMBDA_CLIENT_FUNCTIONS.invoke( - FunctionName=getenv("EVENT_REPLAY"), - InvocationType="RequestResponse", - Payload=dumps(lambda_payload), - ) - response_payload = response["Payload"].read().decode("utf-8") - return response_payload diff --git a/build/docker/test-db-checker-handler/assets/.gitkeep b/test/performance/__init__.py similarity index 100% rename from build/docker/test-db-checker-handler/assets/.gitkeep rename to test/performance/__init__.py diff --git a/test/performance/create_change_events/load_test_locustfile.py b/test/performance/create_change_events/load_test_locustfile.py deleted file mode 100644 index e8d950650..000000000 --- a/test/performance/create_change_events/load_test_locustfile.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any, Dict, Union - -from locust import FastHttpUser, task, constant_pacing -from utilities import setup_change_event_request, ODSCODES, setup_headers, get_api_key - - -class AllChangesChangeEvent(FastHttpUser): - """This class is to test a working change event""" - - weight = 9 - trace_id: Union[str, None] = None - headers: Union[Dict[str, str], None] = None - payload: Union[Dict[str, Any], None] = None - wait_time = constant_pacing(30) - - def on_start(self): - self.api_key = get_api_key() - - @task - def change_event(self): - self.payload = setup_change_event_request() - self.payload["ODSCode"] = ODSCODES.get_valid_ods_code() - self.headers = setup_headers(self.payload["ODSCode"]) - self.headers["x-api-key"] = self.api_key - self.client.post("", headers=self.headers, json=self.payload, name="AllChangesChangeEvent") - - -class OdscodeDoesNotExistInDoS(FastHttpUser): - """This class is to test a change event with an ods code that doesn't exist in DoS""" - - weight = 1 - trace_id: Union[str, None] = None - headers: Union[Dict[str, str], None] = None - payload: Union[Dict[str, Any], None] = None - wait_time = constant_pacing(30) - - def on_start(self): - self.api_key = get_api_key() - - @task - def change_event(self): - self.payload = setup_change_event_request() - self.payload["ODSCode"] = ODSCODES.get_invalid_ods_code() - self.headers = setup_headers(self.payload["ODSCode"]) - self.headers["x-api-key"] = self.api_key - self.client.post("", headers=self.headers, json=self.payload, name="OdscodeDoesNotExistInDoS") diff --git a/test/performance/create_change_events/resources/change_events.json b/test/performance/create_change_events/resources/change_events.json deleted file mode 100644 index e1ebed6b7..000000000 --- a/test/performance/create_change_events/resources/change_events.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "SearchKey": "X36242", - "ODSCode": "FC766", - "OrganisationName": "Test Pharmacy", - "OrganisationTypeId": "PHA", - "OrganisationType": "Pharmacy", - "OrganisationSubType": "Community", - "OrganisationStatus": "Visible", - "Address1": "501-503 MANSFIELD ROAD", - "Address2": null, - "Address3": null, - "City": "NOTTINGHAM", - "County": null, - "Latitude": 53.3723793029785, - "Longitude": -2.12734055519104, - "Postcode": "NG5 2JG", - "ParentOrganisation": { - "ODSCode": "QOP", - "OrganisationName": "Greater Manchester Health and Social Care Partnership (STP)" - }, - "OpeningTimes": [ - { - "Weekday": "Monday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Tuesday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Wednesday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Thursday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Friday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Saturday", - "OpeningTime": "07:30", - "ClosingTime": "23:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - }, - { - "Weekday": "Sunday", - "OpeningTime": "10:00", - "ClosingTime": "17:00", - "OffsetOpeningTime": 540, - "OffsetClosingTime": 780, - "OpeningTimeType": "General", - "AdditionalOpeningDate": "", - "IsOpen": true - } - ], - "Contacts": [ - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Website", - "ContactValue": "http://www.test.co.uk/" - }, - { - "ContactType": "Primary", - "ContactAvailabilityType": "Office hours", - "ContactMethodType": "Telephone", - "ContactValue": "0118 999 88199 9119 725 3" - } - ], - "Facilities": [ - { - "Id": 1, - "Value": "No" - }, - { - "Id": 2, - "Value": "Yes" - }, - { - "Id": 3, - "Value": "No" - }, - { - "Id": 4, - "Value": "Yes" - }, - { - "Id": 5, - "Value": "No" - }, - { - "Id": 6, - "Value": "No" - }, - { - "Id": 35, - "Value": "No" - } - ], - "LastUpdatedDates": { - "OpeningTimes": "2021-09-07T10:21:30+00:00", - "Facilities": "2021-09-07T10:21:42+00:00", - "Services": "2021-09-07T10:21:36+00:00", - "ContactDetails": "2017-10-23T14:06:46+00:00" - } -} diff --git a/test/performance/create_change_events/results/.gitignore b/test/performance/create_change_events/results/.gitignore deleted file mode 100644 index afed0735d..000000000 --- a/test/performance/create_change_events/results/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.csv diff --git a/test/performance/create_change_events/stress_test_locustfile.py b/test/performance/create_change_events/stress_test_locustfile.py deleted file mode 100644 index 34f813e7f..000000000 --- a/test/performance/create_change_events/stress_test_locustfile.py +++ /dev/null @@ -1,44 +0,0 @@ -from typing import Any, Dict, Union - -from locust import FastHttpUser, task -from utilities import setup_change_event_request, ODSCODES, setup_headers, get_api_key - - -class AllChangesChangeEvent(FastHttpUser): - """This class is to test a working change event""" - - weight = 19 - trace_id: Union[str, None] = None - headers: Union[Dict[str, str], None] = None - payload: Union[Dict[str, Any], None] = None - - def on_start(self): - self.api_key = get_api_key() - - @task - def change_event(self): - self.payload = setup_change_event_request() - self.payload["ODSCode"] = ODSCODES.get_valid_ods_code() - self.headers = setup_headers(self.payload["ODSCode"]) - self.headers["x-api-key"] = self.api_key - self.client.post("", headers=self.headers, json=self.payload, name="AllChangesChangeEvent") - - -class OdscodeDoesNotExistInDoS(FastHttpUser): - """This class is to test a change event with an ods code that doesn't exist in DoS""" - - weight = 1 - trace_id: Union[str, None] = None - headers: Union[Dict[str, str], None] = None - payload: Union[Dict[str, Any], None] = None - - def on_start(self): - self.api_key = get_api_key() - - @task - def change_event(self): - self.payload = setup_change_event_request() - self.payload["ODSCode"] = ODSCODES.get_invalid_ods_code() - self.headers = setup_headers(self.payload["ODSCode"]) - self.headers["x-api-key"] = self.api_key - self.client.post("", headers=self.headers, json=self.payload, name="OdscodeDoesNotExistInDoS") diff --git a/test/performance/create_change_events/utilities.py b/test/performance/create_change_events/utilities.py deleted file mode 100644 index 1a3fb0700..000000000 --- a/test/performance/create_change_events/utilities.py +++ /dev/null @@ -1,55 +0,0 @@ -from csv import reader -from json import load, loads -from os import getenv -from random import choice, randint -from time import time_ns -from typing import Any, Union - -from aws import get_secret - - -def setup_change_event_request() -> dict[str, Any]: - """Setup the request headers and json payload for the change event endpoint""" - payload = load(open("resources/change_events.json", "r+")) - payload = make_change_event_unique(payload) - return payload - - -def setup_headers(ods_code: str) -> dict[str, str]: - headers = {"sequence-number": str(time_ns())} - return headers - - -def get_api_key() -> str: - api_key_json = get_secret(getenv("API_KEY_SECRET_NAME")) - api_key = loads(api_key_json)[getenv("API_KEY_SECRET_KEY")] - return api_key - - -def make_change_event_unique(payload: dict[str, Any]) -> dict[str, Any]: - payload["OrganisationName"] = f'{payload["OrganisationName"]} {randint(0, 10000000)}' - return payload - - -class OdsCodes: - invalid_ods_codes: Union[list[list[str]], None] = None - valid_ods_codes: Union[list[list[str]], None] = None - - def __init__(self): - self.valid_ods_codes = self.get_ods_codes_from_file("valid_ods_codes.csv") - self.invalid_ods_codes = self.get_ods_codes_from_file("invalid_ods_codes.csv") - - def get_ods_codes_from_file(self, ods_code_file: str) -> list[list[str]]: - file = open(f"resources/{ods_code_file}", "r") - csv_reader = reader(file) - ods_codes = list(csv_reader) - return ods_codes - - def get_valid_ods_code(self) -> str: - return choice(self.valid_ods_codes)[0] - - def get_invalid_ods_code(self) -> str: - return choice(self.invalid_ods_codes)[0] - - -ODSCODES = OdsCodes() diff --git a/test/performance/data_collection/aws.py b/test/performance/data_collection/aws.py deleted file mode 100644 index 1fbb545a0..000000000 --- a/test/performance/data_collection/aws.py +++ /dev/null @@ -1,42 +0,0 @@ -from datetime import datetime -from os import getenv -from pandas import DataFrame -from boto3 import client - - -def get_queue_details_to_csv(queue_name: str, file_name: str) -> None: - try: - sqs = client("sqs") - response = sqs.get_queue_attributes( - QueueUrl=f'https://sqs.{getenv("AWS_REGION")}.amazonaws.com/{getenv("AWS_ACCOUNT_ID")}/{queue_name}', - AttributeNames=["All"], - ) - dataframe = DataFrame(response["Attributes"], index=[0]) - file_name = file_name.replace(".fifo", "_fifo") - dataframe.to_csv(f"results/{file_name}", index=False) - except Exception as e: - print(f"Exception Occurred when getting SQS Results: {str(e)}") - return response - - -def get_metric_data_to_csv(namespace: str, metric_name: str, dimensions: list, file_name) -> None: - try: - metrics = client("cloudwatch", region_name=getenv("AWS_REGION")) - response = metrics.get_metric_statistics( - Namespace=namespace, - MetricName=metric_name, - Dimensions=dimensions, - StartTime=datetime.strptime(getenv("START_TIME"), ("%Y-%m-%d_%H-%M-%S")), - EndTime=datetime.strptime(getenv("END_TIME"), ("%Y-%m-%d_%H-%M-%S")), - Period=60, - Statistics=["SampleCount", "Average", "Sum", "Minimum", "Maximum"], - ) - if len(response["Datapoints"]) > 0: - dataframe = DataFrame(response["Datapoints"]) - dataframe.sort_values("Timestamp", inplace=True) - dataframe.to_csv(f"results/{file_name}", index=False) - else: - print(f"No metrics {metric_name} found") - except Exception as e: - print(f"Exception Occurred when getting metrics results: Metric={metric_name} Exception={str(e)}") - return response diff --git a/test/performance/data_collection/data_collection.py b/test/performance/data_collection/data_collection.py deleted file mode 100644 index 08b5ac56a..000000000 --- a/test/performance/data_collection/data_collection.py +++ /dev/null @@ -1,69 +0,0 @@ -from os import getenv - -from aws import get_metric_data_to_csv, get_queue_details_to_csv - -CUSTOM_DIMENSIONS = [ - {"Name": "ENV", "Value": getenv("ENVIRONMENT")}, -] - - -def data_collection(): - now = getenv("START_TIME") - get_queue_details_to_csv( - queue_name=getenv("FIFO_QUEUE_NAME"), - file_name=f"{now}_{getenv('FIFO_QUEUE_NAME')}_details.csv", - ) - get_queue_details_to_csv( - queue_name=getenv("FIFO_DLQ_NAME"), - file_name=f"{now}_{getenv('FIFO_DLQ_NAME')}_details.csv", - ) - get_metric_data_to_csv( - namespace="AWS/RDS", - metric_name="DatabaseConnections", - dimensions=[{"Name": "DBInstanceIdentifier", "Value": getenv("RDS_INSTANCE_IDENTIFIER")}], - file_name=f"{now}_db_connections.csv", - ) - get_metric_data_to_csv( - namespace="UEC-DOS-INT", - metric_name="QueueToDoSLatency", - dimensions=CUSTOM_DIMENSIONS, - file_name=f"{now}_queue_to_dos_latency.csv", - ) - get_metric_data_to_csv( - namespace="UEC-DOS-INT", - metric_name="QueueToProcessorLatency", - dimensions=CUSTOM_DIMENSIONS, - file_name=f"{now}_queue_to_processor_latency.csv", - ) - get_metric_data_to_csv( - namespace="UEC-DOS-INT", - metric_name="DosApiLatency", - dimensions=CUSTOM_DIMENSIONS, - file_name=f"{now}_dos_api_latency.csv", - ) - get_metric_data_to_csv( - namespace="UEC-DOS-INT", - metric_name="DoSApiFail", - dimensions=CUSTOM_DIMENSIONS, - file_name=f"{now}_dos_api_fail.csv", - ) - get_metric_data_to_csv( - namespace="AWS/Lambda", - metric_name="ConcurrentExecutions", - dimensions=[ - {"Name": "FunctionName", "Value": getenv("EVENT_PROCESSOR_NAME")}, - ], - file_name=f'{now}_{getenv("EVENT_PROCESSOR_NAME")}_concurrent_executions.csv', - ) - get_metric_data_to_csv( - namespace="AWS/Lambda", - metric_name="Invocations", - dimensions=[ - {"Name": "FunctionName", "Value": getenv("EVENT_PROCESSOR_NAME")}, - ], - file_name=f'{now}_{getenv("EVENT_PROCESSOR_NAME")}_invocations.csv', - ) - - -if __name__ == "__main__": - data_collection() diff --git a/test/performance/data_collection/results/.gitignore b/test/performance/data_collection/results/.gitignore deleted file mode 100644 index afed0735d..000000000 --- a/test/performance/data_collection/results/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.csv diff --git a/test/performance/functions/__init__.py b/test/performance/functions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/performance/functions/api.py b/test/performance/functions/api.py new file mode 100644 index 000000000..7510d104f --- /dev/null +++ b/test/performance/functions/api.py @@ -0,0 +1,35 @@ +from time import time_ns + +from locust import FastHttpUser + +from .ods_codes import ODS_CODES + + +def send_change_event(request_name: str, request: FastHttpUser, valid_ods_code: bool) -> FastHttpUser: + """Send a valid change event. Set playload before calling this function. + + Args: + request_name (str): The name of the request + request (FastHttpUser): The request class + valid_ods_code (bool): Whether to use a valid ods code or not + + + Returns: + FastHttpUser: The change event class + """ + request.payload["ODSCode"] = ODS_CODES.get_valid_ods_code() if valid_ods_code else ODS_CODES.get_invalid_ods_code() + request.payload["OrganisationName"] = f'{request.payload["Address1"]} {time_ns()}' + request.headers = setup_headers() + request.headers["x-api-key"] = request.api_key + request.client.post( + "", + headers=request.headers, + json=request.payload, + name=request_name, + ) + return request + + +def setup_headers() -> dict[str, str]: + """Setup the headers for the change event endpoint.""" + return {"sequence-number": str(time_ns())} diff --git a/test/performance/create_change_events/aws.py b/test/performance/functions/aws.py similarity index 61% rename from test/performance/create_change_events/aws.py rename to test/performance/functions/aws.py index da886f63a..63b8a4830 100644 --- a/test/performance/create_change_events/aws.py +++ b/test/performance/functions/aws.py @@ -2,6 +2,14 @@ def get_secret(secret_name: str) -> str: + """Get a secret from AWS Secrets Manager. + + Args: + secret_name (str): The name of the secret + + Returns: + str: The secret + """ sm = client(service_name="secretsmanager") get_secret_value_response = sm.get_secret_value(SecretId=secret_name) return get_secret_value_response["SecretString"] diff --git a/test/performance/functions/change_event.py b/test/performance/functions/change_event.py new file mode 100644 index 000000000..4ab789bc5 --- /dev/null +++ b/test/performance/functions/change_event.py @@ -0,0 +1,146 @@ +from json import load +from random import choice +from time import time_ns +from typing import Self + +from .data import CLOSE_TIMES, DAYS, POSTCODES, SPECIFIED_OPENING_DATES, START_TIMES + + +class ChangeEvent: + """A class to represent a change event.""" + + # Demographics - Contact + website: str + public_phone: str + + # Demographics - Location + address: str + postcode: str + + # Opening times + standard_opening_times: dict[str, dict[str, str]] + specific_opening_times: dict[str, dict[str, str]] + + # Service types + palliative_care: bool + blood_pressure: bool + contraception: bool + + # Change event json + _change_event_json: dict[str, str] + + def __init__(self: Self) -> None: + """Initialise the class.""" + with open("resources/change_event.json", "r+") as file: + file_contents = load(file) + self.website = file_contents["Contacts"][0]["ContactValue"] + self.public_phone = file_contents["Contacts"][1]["ContactValue"] + self.address = file_contents["Address1"] + self.postcode = file_contents["Postcode"] + self.standard_opening_times = {} + self.specific_opening_times = {} + self.palliative_care = False + self.blood_pressure = False + self.contraception = False + + def cause_contact_updates(self: Self) -> None: + """Cause a location update.""" + self.website = f"{self.website} {time_ns()}" + self.public_phone = f"{self.public_phone} {time_ns()}" + + def cause_location_updates(self: Self) -> None: + """Cause a location update.""" + self.address = f"{self.address} {time_ns()}" + self.postcode = choice(POSTCODES) + + def cause_opening_times_updates(self: Self) -> None: + """Cause an opening times update.""" + self.standard_opening_times = { + choice(DAYS): { + "OpeningTime": choice(START_TIMES), + "ClosingTime": choice(CLOSE_TIMES), + }, + } + self.specific_opening_times = { + choice(SPECIFIED_OPENING_DATES): { + "OpeningTime": choice(START_TIMES), + "ClosingTime": choice(CLOSE_TIMES), + }, + } + + def cause_palliative_care_updates(self: Self) -> None: + """Cause a palliative care update.""" + self.palliative_care = True + + def cause_blood_pressure_updates(self: Self) -> None: + """Cause a blood pressure update.""" + self.blood_pressure = True + + def cause_contraception_updates(self: Self) -> None: + """Cause a contraception update.""" + self.contraception = True + + def create_change_event_json(self: Self) -> dict[str, str]: + """Create a change event json payload.""" + with open("resources/change_event.json", "r+") as file: + file_contents = load(file) + file_contents["Contacts"][0]["ContactValue"] = self.website + file_contents["Contacts"][1]["ContactValue"] = self.public_phone + file_contents["Address1"] = self.address + file_contents["Postcode"] = self.postcode + file_contents["OpeningTimes"] = self._update_change_event_json_with_opening_times() + file_contents["UecServices"] = self._update_change_event_json_with_uec_services() + file_contents["Services"] = self._update_change_event_json_with_service() + return file_contents + + def _update_change_event_json_with_opening_times(self: Self) -> list[dict[str, str]]: + opening_times = [ + { + "Weekday": day, + "OpeningTime": times["OpeningTime"], + "ClosingTime": times["ClosingTime"], + "OpeningTimeType": "General", + "IsOpen": True, + } + for day, times in self.standard_opening_times.items() + ] + opening_times.extend( + { + "AdditionalOpeningDate": date, + "OpeningTime": times["OpeningTime"], + "ClosingTime": times["ClosingTime"], + "OpeningTimeType": "Additional", + "IsOpen": True, + } + for date, times in self.specific_opening_times.items() + ) + return opening_times + + def _update_change_event_json_with_uec_services(self: Self) -> list: + uec_services = [] + if self.palliative_care: + uec_services.append( + { + "ServiceName": "Pharmacy palliative care medication stockholder", + "ServiceCode": "SRV0559", + }, + ) + return uec_services + + def _update_change_event_json_with_service(self: Self) -> list: + service = [] + if self.blood_pressure: + service.append( + { + "ServiceName": "NHS blood pressure check service", + "ServiceCode": "SRV0560", + }, + ) + if self.contraception: + service.append( + { + "ServiceName": "NHS Pharmacy Contraception Service", + "ServiceCode": "SRV2000", + }, + ) + return service diff --git a/test/performance/functions/data.py b/test/performance/functions/data.py new file mode 100644 index 000000000..fbd921e0a --- /dev/null +++ b/test/performance/functions/data.py @@ -0,0 +1,22 @@ +POSTCODES = ["LS1 4AP", "EX2 5SE", "E14 4PU", "PR8 2HH"] + +DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + +SPECIFIED_OPENING_DATES = [ + "Jan 1 2030", + "Feb 1 2030", + "Mar 1 2030", + "Apr 1 2030", + "May 1 2030", + "Jun 1 2030", + "Jul 1 2030", + "Aug 1 2030", + "Sep 1 2030", + "Oct 1 2030", + "Nov 1 2030", + "Dec 1 2030", +] + +START_TIMES = ["07:00", "07:30", "08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00", "11:30"] + +CLOSE_TIMES = ["16:00", "16:30", "17:00", "17:30", "18:00", "18:30", "19:00", "19:30"] diff --git a/test/performance/functions/ods_codes.py b/test/performance/functions/ods_codes.py new file mode 100644 index 000000000..ddbce343d --- /dev/null +++ b/test/performance/functions/ods_codes.py @@ -0,0 +1,64 @@ +from csv import reader +from random import choice +from typing import Self + + +class OdsCodes: + """Class to get valid and invalid pharmacy ods codes.""" + + invalid_ods_codes: list[list[str]] | None = None + valid_ods_codes: list[list[str]] | None = None + + def get_ods_codes_from_file(self: Self, ods_code_file: str) -> list[list[str]]: + """Get the ods codes from a file. + + Args: + ods_code_file (str): The name of the file to get the ods codes from + + Returns: + list[list[str]]: The list of ods codes + """ + with open(f"resources/{ods_code_file}") as file: + csv_reader = reader(file) + return list(csv_reader) + + def generic_get_ods_code( + self: Self, + ods_code_file_name: str, + odscode_list: list[list[str]] | None, + ) -> tuple[str, list[list[str]]]: + """Get a random ods code from list or file if list is empty. + + Args: + ods_code_file_name (str): The name of the file to get the ods codes from if the list is empty + odscode_list (Optional[list[list[str]]]): The list of ods codes to get the odscode from + + Returns: + Tuple[str, list[list[str]]]: The odscode and the list of ods codes + """ + if odscode_list is None or len(odscode_list) == 0: + odscode_list = self.get_ods_codes_from_file(ods_code_file_name) + odscode_list_of_one = choice(odscode_list) + odscode_list.remove(odscode_list_of_one) + return odscode_list_of_one[0], odscode_list + + def get_valid_ods_code(self: Self) -> str: + """Get a valid pharmacy ods code. + + Returns: + str: The valid ods code + """ + odscode, self.valid_ods_codes = self.generic_get_ods_code("valid_ods_codes.csv", self.valid_ods_codes) + return odscode + + def get_invalid_ods_code(self: Self) -> str: + """Get an invalid pharmacy ods code. + + Returns: + str: The invalid ods code + """ + odscode, self.invalid_ods_codes = self.generic_get_ods_code("invalid_ods_codes.csv", self.invalid_ods_codes) + return odscode + + +ODS_CODES = OdsCodes() diff --git a/test/performance/functions/performance_test.py b/test/performance/functions/performance_test.py new file mode 100644 index 000000000..a424e9340 --- /dev/null +++ b/test/performance/functions/performance_test.py @@ -0,0 +1,121 @@ +from typing import Any, Self + +from locust import FastHttpUser, task + +from functions.api import send_change_event +from functions.change_event import ChangeEvent +from functions.utilities import get_api_key + + +class PerformanceTest(FastHttpUser): + """This class is to run the performance test. + + This test runs like a stress test. Use a delay to control the rate of requests. + """ + + abstract = True + headers: dict[str, str] + payload: dict[str, Any] + + def on_start(self: Self) -> None: + """Get the api key before starting the test.""" + self.api_key = get_api_key() + + @task(3) + def all_updates_change_event(self: Self) -> None: + """Send an all changes change event. + + Args: + self (Self): The class + """ + change_event = ChangeEvent() + change_event.cause_contact_updates() + change_event.cause_location_updates() + change_event.cause_opening_times_updates() + change_event.cause_blood_pressure_updates() + change_event.cause_contraception_updates() + self.payload = change_event.create_change_event_json() + send_change_event(request_name="AllChangesChangeEvent", request=self, valid_ods_code=True) + + @task + def contact_updates_change_event(self: Self) -> None: + """Send a contact change event. + + Args: + self (Self): The class + """ + change_event = ChangeEvent() + change_event.cause_contact_updates() + self.payload = change_event.create_change_event_json() + send_change_event(request_name="ContactChangeEvent", request=self, valid_ods_code=True) + + @task + def location_updates_change_event(self: Self) -> None: + """Send a location change event. + + Args: + self (Self): The class + """ + change_event = ChangeEvent() + change_event.cause_location_updates() + self.payload = change_event.create_change_event_json() + send_change_event(request_name="LocationChangeEvent", request=self, valid_ods_code=True) + + @task(2) + def opening_times_updates_change_event(self: Self) -> None: + """Send an opening times change event. + + Args: + self (Self): The class + """ + change_event = ChangeEvent() + change_event.cause_opening_times_updates() + self.payload = change_event.create_change_event_json() + send_change_event(request_name="OpeningTimesChangeEvent", request=self, valid_ods_code=True) + + @task + def palliative_care_changes_change_event(self: Self) -> None: + """Send a palliative care change event. + + Args: + self (Self): The class + """ + change_event = ChangeEvent() + change_event.cause_palliative_care_updates() + self.payload = change_event.create_change_event_json() + send_change_event(request_name="PalliativeCareChangeEvent", request=self, valid_ods_code=True) + + @task + def blood_pressure_updates_change_event(self: Self) -> None: + """Send a blood pressure change event. + + Args: + self (Self): The class + """ + change_event = ChangeEvent() + change_event.cause_blood_pressure_updates() + self.payload = change_event.create_change_event_json() + send_change_event(request_name="BloodPressureChangeEvent", request=self, valid_ods_code=True) + + @task + def contraception_updates_change_event(self: Self) -> None: + """Send a contraception change event. + + Args: + self (Self): The class + """ + change_event = ChangeEvent() + change_event.cause_contraception_updates() + self.payload = change_event.create_change_event_json() + send_change_event(request_name="ContraceptionChangeEvent", request=self, valid_ods_code=True) + + @task + def no_match_change_event(self: Self) -> None: + """Send a no match change event. + + Args: + self (Self): The class + """ + change_event = ChangeEvent() + self.payload = change_event.create_change_event_json() + send_change_event(request_name="NoMatchChangeEvent", request=self, valid_ods_code=False) diff --git a/test/performance/functions/utilities.py b/test/performance/functions/utilities.py new file mode 100644 index 000000000..f4b9ff959 --- /dev/null +++ b/test/performance/functions/utilities.py @@ -0,0 +1,10 @@ +from json import loads +from os import getenv + +from .aws import get_secret + + +def get_api_key() -> str: + """Get the api key from AWS secrets manager.""" + api_key_json = get_secret(getenv("API_KEY_SECRET")) + return loads(api_key_json)[getenv("NHS_UK_API_KEY")] diff --git a/test/performance/load_test.py b/test/performance/load_test.py new file mode 100644 index 000000000..2b11fab17 --- /dev/null +++ b/test/performance/load_test.py @@ -0,0 +1,12 @@ +from locust import constant_pacing + +from functions.performance_test import PerformanceTest + + +class LoadTest(PerformanceTest): + """This class is to run the load test. + + This is a slower version of the stress test by using constant_pacing. + """ + + wait_time = constant_pacing(10) diff --git a/test/performance/resources/change_event.json b/test/performance/resources/change_event.json new file mode 100644 index 000000000..cc72f8d3c --- /dev/null +++ b/test/performance/resources/change_event.json @@ -0,0 +1,36 @@ +{ + "ODSCode": "FC766", + "OrganisationName": "Test Pharmacy", + "OrganisationTypeId": "PHA", + "OrganisationSubType": "Community", + "OrganisationStatus": "Visible", + "Address1": "501 MANSFIELD ROAD", + "Address2": null, + "Address3": null, + "City": "NOTTINGHAM", + "County": null, + "Postcode": "TA3 7QY", + "OpeningTimes": [], + "Contacts": [ + { + "ContactType": "Primary", + "ContactAvailabilityType": "Office hours", + "ContactMethodType": "Website", + "ContactValue": "http://www.test.co.uk/" + }, + { + "ContactType": "Primary", + "ContactAvailabilityType": "Office hours", + "ContactMethodType": "Telephone", + "ContactValue": "0118 999 88199 9119 725 3" + } + ], + "UecServices": [], + "Services": [], + "LastUpdatedDates": { + "OpeningTimes": "2021-09-07T10:21:30+00:00", + "Facilities": "2021-09-07T10:21:42+00:00", + "Services": "2021-09-07T10:21:36+00:00", + "ContactDetails": "2017-10-23T14:06:46+00:00" + } +} diff --git a/test/performance/create_change_events/resources/invalid_ods_codes.csv b/test/performance/resources/invalid_ods_codes.csv similarity index 100% rename from test/performance/create_change_events/resources/invalid_ods_codes.csv rename to test/performance/resources/invalid_ods_codes.csv diff --git a/test/performance/create_change_events/resources/throws_error_in_performance.csv b/test/performance/resources/throws_error_in_performance.csv similarity index 100% rename from test/performance/create_change_events/resources/throws_error_in_performance.csv rename to test/performance/resources/throws_error_in_performance.csv diff --git a/test/performance/create_change_events/resources/valid_ods_codes.csv b/test/performance/resources/valid_ods_codes.csv similarity index 100% rename from test/performance/create_change_events/resources/valid_ods_codes.csv rename to test/performance/resources/valid_ods_codes.csv diff --git a/test/performance/stress_test.py b/test/performance/stress_test.py new file mode 100644 index 000000000..a7a6f825e --- /dev/null +++ b/test/performance/stress_test.py @@ -0,0 +1,5 @@ +from functions.performance_test import PerformanceTest + + +class StressTest(PerformanceTest): + """This class is to run the stress test.""" diff --git a/test/pyproject.toml b/test/pyproject.toml new file mode 100644 index 000000000..b734dba11 --- /dev/null +++ b/test/pyproject.toml @@ -0,0 +1,37 @@ +[tool.ruff] +extend = "../pyproject.toml" + +lint.ignore = [ + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package + "PTH123", # Use `pathlib.Path` instead of `os.path`, + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes + "T201", #`print` found + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `expected_data` + "FBT001", # Boolean positional arg in function definition + "FBT002", # Boolean keyword arg in function definition + "FBT003", # Boolean positional value in function call + "PLR2004", # Magic value used in comparison, consider replacing value with a constant variable + "DTZ007", # the use of `datetime.datetime.strptime()` without %z must be followed by `.replace(tzinfo=)` or `.astimezone()` +] + +# Set python version to 3.12. +target-version = "py312" + +[tool.ruff.lint.per-file-ignores] +"**test_*.py" = ["S101"] # Allow `assert` in tests. +"integration**.py" = ["S101"] # Allow `assert` in tests. +"smoke**.py" = ["S101"] # Allow `assert` in tests. +"conftest.py" = [ + "ANN001", + "PLR0913", + "ARG001", + "BLE001", +] # Conftest files break many rules. + +"performance/functions/performance_test.py" = [ + "ERA001", +] # Allow commented out code in performance tests. + +[tool.ruff.lint.pydocstyle] +convention = "google" # Use Google docstring convention. diff --git a/test/smoke/__init__.py b/test/smoke/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/smoke/conftest.py b/test/smoke/conftest.py new file mode 100644 index 000000000..2658a961e --- /dev/null +++ b/test/smoke/conftest.py @@ -0,0 +1,17 @@ +def log_test_summary(step, request) -> None: + """Log test summary.""" + print(f"Step Summary: For {step}") + try: + print(request.getfixturevalue("smoke_test_context")) + except Exception: + print("No smoke_test_context") + + +def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args, exception) -> None: + """Called after a step function raised an exception.""" + log_test_summary(step, request) + + +def pytest_bdd_after_step(request, feature, scenario, step, step_func, step_func_args) -> None: + """Called after a step function call.""" + log_test_summary(step, request) diff --git a/test/smoke/functions/__init__.py b/test/smoke/functions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/smoke/functions/aws.py b/test/smoke/functions/aws.py new file mode 100644 index 000000000..a9a1abde1 --- /dev/null +++ b/test/smoke/functions/aws.py @@ -0,0 +1,85 @@ +from json import dumps +from os import getenv +from time import sleep + +from boto3 import client + + +def get_secret(secret_name: str) -> str: + """Get secret from AWS Secrets Manager. + + Args: + secret_name (str): Get secret from AWS Secrets Manager. + + Returns: + str: Secret value. + """ + secrets_manager = client(service_name="secretsmanager") + get_secret_value_response = secrets_manager.get_secret_value(SecretId=secret_name) + return get_secret_value_response["SecretString"] + + +def get_latest_sequence_id_for_a_given_odscode(odscode: str) -> int: + """Get latest sequence id for a given odscode from dynamodb. + + Args: + odscode (str): ODSCode. + + Raises: + Exception: Unable to get sequence id from dynamodb + + Returns: + int: Latest sequence id for a given odscode from dynamodb. + """ + dynamodb_client = client(service_name="dynamodb") + dynamodb_table = getenv("DYNAMO_DB_TABLE") + try: + resp = dynamodb_client.query( + TableName=dynamodb_table, + IndexName="gsi_ods_sequence", + KeyConditionExpression="ODSCode = :odscode", + ExpressionAttributeValues={ + ":odscode": {"S": odscode}, + }, + Limit=1, + ScanIndexForward=False, + ProjectionExpression="ODSCode,SequenceNumber", + ) + sequence_number = 0 + if resp.get("Count") > 0: + sequence_number = int(resp.get("Items")[0]["SequenceNumber"]["N"]) + except Exception as err: + print(f"Unable to get sequence id from dynamodb for a given ODSCode {odscode} {dynamodb_table} .Error: {err}") + raise + return sequence_number + + +def invoke_dos_db_handler_lambda(lambda_payload: dict) -> str: + """Invoke dos db handler lambda. + + Args: + lambda_payload (dict): Lambda payload. + + Returns: + str: Lambda response payload (json). + """ + lambda_client = client("lambda") + response_status = False + response = None + retries = 0 + while not response_status: + response = lambda_client.invoke( + FunctionName=getenv("DOS_DB_HANDLER_LAMBDA"), + InvocationType="RequestResponse", + Payload=dumps(lambda_payload), + ) + response_payload = response["Payload"].read().decode("utf-8") + if "errorMessage" not in response_payload: + return response_payload + + if retries > 6: + msg = f"Unable to run DoS db handler lambda successfully after {retries} retries: {response_payload}" + raise ValueError(msg) + retries += 1 + sleep(10) + return response diff --git a/test/smoke/functions/base_change_event.json b/test/smoke/functions/base_change_event.json new file mode 100644 index 000000000..92a3c4c85 --- /dev/null +++ b/test/smoke/functions/base_change_event.json @@ -0,0 +1,18 @@ +{ + "ODSCode": "FDL64", + "OrganisationName": "Pharmacy Name", + "OrganisationTypeId": "PHA", + "OrganisationSubType": "Community", + "OrganisationStatus": "Visible", + "Address1": "", + "Address2": "", + "Address3": "", + "City": "", + "County": "", + "Postcode": "TA3 7QY", + "OpeningTimes": [], + "Contacts": [], + "LastUpdatedDates": {}, + "Services": [], + "UecServices": [] +} diff --git a/test/smoke/functions/change_event.py b/test/smoke/functions/change_event.py new file mode 100644 index 000000000..ea94596a4 --- /dev/null +++ b/test/smoke/functions/change_event.py @@ -0,0 +1,190 @@ +from dataclasses import dataclass, field +from datetime import datetime +from json import load +from typing import Self + +from .constants import NHS_UK_BLOOD_PRESSURE_SERVICE_CODE, NHS_UK_CONTRACEPTION_SERVICE_CODE + + +@dataclass(init=True, repr=True) +class ChangeEvent: + """A change event.""" + + address: str + website: str + phone: str # Public phone number + standard_opening_times: field(default_factory=list) + specified_opening_times: field(default_factory=list) + blood_pressure: bool + contraception: bool + + def create_change_event(self: Self) -> dict: + """Create a change event from the base change event and set the attributes. + + Returns: + dict: The change event + """ + """Create a change event from base including the set attributes.""" + base_change_event = self._load_base_change_event() + base_change_event = self._set_address(base_change_event) + base_change_event = self._set_contact_details(base_change_event, "Website", self.website) + base_change_event = self._set_contact_details(base_change_event, "Telephone", self.phone) + base_change_event = self._set_opening_times(base_change_event) + base_change_event = self._set_commissioned_services( + base_change_event, + self.blood_pressure, + NHS_UK_BLOOD_PRESSURE_SERVICE_CODE, + ) + return self._set_commissioned_services( + base_change_event, + self.contraception, + NHS_UK_CONTRACEPTION_SERVICE_CODE, + ) + + def _load_base_change_event(self: Self) -> dict: + """Load the base change event from the JSON file. + + Returns: + dict: The base change event + """ + with open("./functions/base_change_event.json") as base_change_event_file: + return load(base_change_event_file) + + def _set_address(self: Self, base_change_event: dict) -> dict: + """Set the address attributes on the change event. + + Args: + base_change_event (dict): The base change event + + Returns: + dict: The change event + """ + address_line_1, address_line_2, address_line_3, city, county = self._split_dos_address(self.address) + base_change_event["Address1"] = address_line_1 + base_change_event["Address2"] = address_line_2 + base_change_event["Address3"] = address_line_3 + base_change_event["City"] = city + base_change_event["County"] = county + return base_change_event + + def _set_contact_details(self: Self, base_change_event: dict, change_event_name: str, value: str) -> dict: + """Set the contact details attributes on the change event. + + Args: + base_change_event (dict): The base change event + change_event_name (str): The name of the change event + value (str): The value of the change event + + Returns: + dict: The change event + """ + base_change_event["Contacts"].append( + { + "ContactType": "Primary", + "ContactAvailabilityType": "Office hours", + "ContactMethodType": change_event_name, + "ContactValue": value, + }, + ) + return base_change_event + + def _split_dos_address(self: Self, dos_address: str) -> tuple[str, str, str, str, str]: + """Split a DoS address into its constituent parts. + + Args: + dos_address (str): The DoS address to split + + Returns: + tuple[str, str, str, str, str]: The address line 1, address line 2, address line 3, city and county + """ + dos_address = dos_address.split("$", 4) + address_line_1 = "" + address_line_2 = "" + address_line_3 = "" + city = "" + county = "" + match len(dos_address): + case 1: + address_line_1 = dos_address[0] + case 2: + address_line_1 = dos_address[0] + address_line_2 = dos_address[1] + case 3: + address_line_1 = dos_address[0] + address_line_2 = dos_address[1] + address_line_3 = dos_address[2] + case 4: + address_line_1 = dos_address[0] + address_line_2 = dos_address[1] + address_line_3 = dos_address[2] + city = dos_address[3] + case 5: + address_line_1 = dos_address[0] + address_line_2 = dos_address[1] + address_line_3 = dos_address[2] + city = dos_address[3] + county = dos_address[4] + case _: + msg = f"DoS address '{dos_address}' is not in the correct format" + raise ValueError(msg) + + return address_line_1, address_line_2, address_line_3, city, county + + def _set_opening_times(self: Self, base_change_event: dict) -> dict: + """Set the opening times attributes on the change event. + + Args: + base_change_event (dict): The base change event + + Returns: + dict: The change event + """ + base_change_event["OpeningTimes"].extend( + { + "ClosingTime": day["close"], + "IsOpen": day["open_or_closed"], + "OpeningTime": day["open"], + "OpeningTimeType": "General", + "Weekday": day["day"], + } + for day in self.standard_opening_times + ) + + for specified_opening_date in self.specified_opening_times: + if isinstance(specified_opening_date["date"], str): + specified_opening_date["date"] = datetime.strptime(specified_opening_date["date"], "%Y-%m-%d").date() + + base_change_event["OpeningTimes"].extend( + { + "AdditionalOpeningDate": specified_opening_date["date"].strftime("%b %d %Y"), + "ClosingTime": specified_opening_date["close"], + "IsOpen": specified_opening_date["open_or_closed"], + "OpeningTime": specified_opening_date["open"], + "OpeningTimeType": "Additional", + } + for specified_opening_date in self.specified_opening_times + ) + return base_change_event + + def _set_commissioned_services( + self: Self, base_change_event: dict, service_enabled: bool, service_code: str + ) -> dict: + """Set the commissioned services on services attribute on the change event. + + Args: + base_change_event (dict): The base change event + service_enabled (bool): Whether the service is enabled + service_code (str): The service code + + Returns: + dict: The change event + """ + if service_enabled: + base_change_event["Services"].append( + { + "ServiceName": "Service name", + "ServiceDescription": None, + "ServiceCode": service_code, + }, + ) + return base_change_event diff --git a/test/smoke/functions/change_event_request.py b/test/smoke/functions/change_event_request.py new file mode 100644 index 000000000..f4689f747 --- /dev/null +++ b/test/smoke/functions/change_event_request.py @@ -0,0 +1,43 @@ +from json import dumps, loads +from os import getenv +from time import time_ns + +from requests import Response, post + +from .aws import get_latest_sequence_id_for_a_given_odscode, get_secret + + +def send_change_event(change_event_json: dict) -> Response: + """Send change event to DoS Integration API. + + Args: + change_event_json (dict): The change event to send + """ + api_key = loads(get_secret(getenv("API_KEY_SECRET")))[getenv("NHS_UK_API_KEY")] + sequence_number = generate_unique_sequence_number(change_event_json["ODSCode"]) + correlation_id = f"{time_ns()}-Smoke-Test" + print(f"Sending change event with sequence number {sequence_number} and correlation ID {correlation_id}") + api_gateway_url = getenv("HTTPS_DOS_INTEGRATION_URL") + headers = { + "x-api-key": api_key, + "sequence-number": sequence_number, + "correlation-id": correlation_id, + "Content-Type": "application/json", + } + response = post(url=api_gateway_url, headers=headers, data=dumps(change_event_json), timeout=10) + if response.status_code != 200: + msg = f"Unable to process change request payload. Error: {response.text}" + raise ValueError(msg) + return response + + +def generate_unique_sequence_number(odscode: str) -> str: + """Generate unique sequence number. + + Args: + odscode (str): ODSCode. + + Returns: + str: Unique sequence number. + """ + return str(get_latest_sequence_id_for_a_given_odscode(odscode) + 1) diff --git a/test/smoke/functions/constants.py b/test/smoke/functions/constants.py new file mode 100644 index 000000000..2d1090f4d --- /dev/null +++ b/test/smoke/functions/constants.py @@ -0,0 +1,14 @@ +# General +DEFAULT_ODS_CODE = "FDL64" + +# Service Statuses +DOS_ACTIVE_STATUS_ID = 1 +DOS_CLOSED_STATUS_ID = 2 + +# Blood Pressure +DOS_BLOOD_PRESSURE_TYPE_ID = 148 +NHS_UK_BLOOD_PRESSURE_SERVICE_CODE = "SRV0560" + +# Contraception +DOS_CONTRACEPTION_TYPE_ID = 149 +NHS_UK_CONTRACEPTION_SERVICE_CODE = "SRV2000" diff --git a/test/smoke/functions/service.py b/test/smoke/functions/service.py new file mode 100644 index 000000000..07d05f0fd --- /dev/null +++ b/test/smoke/functions/service.py @@ -0,0 +1,431 @@ +from datetime import datetime +from json import loads +from time import sleep + +from pytz import UTC + +from .aws import invoke_dos_db_handler_lambda +from .change_event import ChangeEvent +from .constants import ( + DEFAULT_ODS_CODE, + DOS_BLOOD_PRESSURE_TYPE_ID, + DOS_CONTRACEPTION_TYPE_ID, +) +from .smoke_test_context import SmokeTestContext +from .types import Demographics +from .utilities import seconds_since_midnight + + +def get_change_event_for_service(ods_code: str) -> ChangeEvent: + """Get a service from DoS. + + Args: + ods_code (str): The ODS code of the service to get + """ + service_id = get_main_service_id_for_ods_code(ods_code) + demographics = get_demographics_for_service(service_id) + standard_opening_times = get_standard_opening_times_for_service(service_id) + specified_opening_times = get_specified_opening_times_for_service(service_id) + blood_pressure = get_blood_pressure(ods_code) + contraception = get_contraception(ods_code) + + return ChangeEvent( + address=demographics["address"], + website=demographics["website"], + phone=demographics["phone"], + standard_opening_times=standard_opening_times, + specified_opening_times=specified_opening_times, + blood_pressure=blood_pressure, + contraception=contraception, + ) + + +def get_main_service_id_for_ods_code(ods_code: str) -> str: + """Get the service ID for an ODS code. + + Args: + ods_code (str): The ODS code to get the service ID for + + Returns: + str: The service ID for the ODS code + """ + query = "SELECT id FROM services WHERE odscode = %(ODS_CODE)s AND LENGTH(odscode) = 5" + response = invoke_dos_db_handler_lambda({"type": "read", "query": query, "query_vars": {"ODS_CODE": ods_code}}) + response = loads(loads(response)) + return response[0]["id"] + + +def get_service_id_for_ods_code_with_type_id(ods_code: str, type_id: int) -> int: + """Get the service ID for an ODS code. + + Args: + ods_code (str): The ODS code to get the service ID for + type_id (int): The type ID to get the service ID for + + Returns: + str: The service ID for the ODS code + """ + query = "SELECT id FROM services WHERE odscode LIKE %(ODS_CODE)s AND typeid = %(TYPE_ID)s" + response = invoke_dos_db_handler_lambda( + {"type": "read", "query": query, "query_vars": {"ODS_CODE": f"{ods_code}%", "TYPE_ID": type_id}}, + ) + response = loads(loads(response)) + return response[0]["id"] + + +def get_demographics_for_service(service_id: str) -> Demographics: + """Get the demographics for a service. + + Args: + service_id (str): The service ID to get the demographics for + + Returns: + Demographics: The demographics for the service + """ + query = "SELECT address, web, publicphone FROM services WHERE id = %(SERVICE_ID)s" + response = invoke_dos_db_handler_lambda({"type": "read", "query": query, "query_vars": {"SERVICE_ID": service_id}}) + response_list = loads(loads(response)) + response_dict = response_list[0] + return { + "address": response_dict["address"], + "website": response_dict["web"], + "phone": response_dict["publicphone"], + } + + +def get_standard_opening_times_for_service(service_id: str) -> list[dict | None]: + """Get the standard opening times for a service. + + Args: + service_id (str): The service ID to get the standard opening times for + + Returns: + list[dict | None]: The standard opening times for the service + """ + opening_periods = [] + response = invoke_dos_db_handler_lambda({"type": "change_event_standard_opening_times", "service_id": service_id}) + response = loads(response) + for day, values in response.items(): + opening_periods.extend( + { + "day": day, + "open": opening_period["start_time"], + "close": opening_period["end_time"], + "open_or_closed": True, + } + for opening_period in values + ) + return opening_periods + + +def get_specified_opening_times_for_service(service_id: str) -> list[dict | None]: + """Get the specified opening times for a service. + + Args: + service_id (str): The service ID to get the specified opening times for + + Returns: + dict: The specified opening times for the service + """ + opening_periods = [] + response = invoke_dos_db_handler_lambda({"type": "change_event_specified_opening_times", "service_id": service_id}) + response = loads(response) + for date, values in response.items(): + opening_periods.extend( + { + "date": date, + "open": opening_period["start_time"], + "close": opening_period["end_time"], + "open_or_closed": True, + } + for opening_period in values + ) + return opening_periods + + +def get_blood_pressure(odscode: str) -> bool: + """Get the blood pressure status for a service. + + Args: + odscode (str): The ODS code to get the blood pressure status for which is used to get the service ID + + Returns: + bool: The blood pressure status for the service + """ + service_id = get_service_id_for_ods_code_with_type_id(odscode, DOS_BLOOD_PRESSURE_TYPE_ID) + return get_service_status(service_id) + + +def get_contraception(odscode: str) -> bool: + """Get the contraception status for a service. + + Args: + odscode (str): The ODS code to get the contraception status for which is used to get the service ID + + Returns: + bool: The contraception status for the service + """ + service_id = get_service_id_for_ods_code_with_type_id(odscode, DOS_CONTRACEPTION_TYPE_ID) + return get_service_status(service_id) + + +def get_service_status(service_id: int) -> bool: + """Get the service status for a service. + + Args: + service_id (str): The service ID to get the service status for + + Returns: + bool: The service status for the service (True = active, False = closed/commissioning) + """ + query = "SELECT statusid FROM services WHERE id = %(SERVICE_ID)s" + response = invoke_dos_db_handler_lambda({"type": "read", "query": query, "query_vars": {"SERVICE_ID": service_id}}) + response = loads(loads(response)) + return response[0]["statusid"] == 1 + + +def get_service_history(service_id: str) -> dict: + """Get the service history for a service. + + Args: + service_id (str): The service ID to get the service history for + + Returns: + dict: The service history for the service + """ + data = [] + retry_counter = 0 + query = "SELECT history FROM servicehistories WHERE serviceid = %(SERVICE_ID)s" + max_retry = 2 + while not data and retry_counter < max_retry: + query_vars = {"SERVICE_ID": service_id} + response = invoke_dos_db_handler_lambda({"type": "read", "query": query, "query_vars": query_vars}) + data = loads(loads(response)) + retry_counter += 1 + sleep(30) + return loads(data[0]["history"]) + + +def get_service_modified_time(service_id: str) -> str: + """Get the modifiedtime for a service. + + Args: + service_id (str): The service ID to get the modifiedtime for + + Returns: + str: The modifiedtime for the service + """ + query = "SELECT modifiedtime FROM services WHERE id = %(SERVICE_ID)s" + response = invoke_dos_db_handler_lambda({"type": "read", "query": query, "query_vars": {"SERVICE_ID": service_id}}) + response = loads(loads(response)) + return response[0]["modifiedtime"] + + +def wait_for_service_update(response_start_time: datetime) -> None: + """Wait for the service to be updated by checking modifiedtime. + + Args: + response_start_time (datetime): The time the response was started + """ + service_id = get_main_service_id_for_ods_code(DEFAULT_ODS_CODE) + updated_date_time = None + sleep(30) + for _ in range(12): + sleep(10) + updated_date_time_str: str = get_service_modified_time(service_id) + updated_date_time = datetime.strptime(updated_date_time_str, "%Y-%m-%d %H:%M:%S%z") + updated_date_time = updated_date_time.replace(tzinfo=UTC) + response_start_time = response_start_time.replace(tzinfo=UTC) + if updated_date_time > response_start_time: + break + else: + msg = f"Service not updated, service_id: {service_id}, modifiedtime: {updated_date_time}" + raise ValueError(msg) + + +def check_demographic_field_updated(field: str, service_history_key: str, expected_value: str) -> None: + """Check that the demographic field was updated in the services table and in service history. + + Args: + field (str): The demographic field to check + service_history_key (str): The key in the service history to check + expected_value (str): The expected value of the demographic field + """ + + def assert_field_updated() -> None: + query = f"SELECT {field} FROM services WHERE id = %(SERVICE_ID)s" # noqa: S608 + response = invoke_dos_db_handler_lambda( + {"type": "read", "query": query, "query_vars": {"SERVICE_ID": service_id}}, + ) + response = loads(loads(response)) + assert ( + response[0][field] == expected_value + ), f"Demographic field {field} was not updated - expected: '{expected_value}', actual: '{response[0][field]}'" + + def assert_field_updated_in_history() -> None: + history = get_service_history(service_id) + first_key_in_service_history = next(iter(history.keys())) + new_history = history[first_key_in_service_history]["new"] + assert ( + expected_value == new_history[service_history_key]["data"] + ), f"Expected data: {expected_value}, Expected data type: {type(expected_value)}, Actual data: {new_history[service_history_key]['data']}" # noqa: E501 + + service_id = get_main_service_id_for_ods_code(DEFAULT_ODS_CODE) + assert_field_updated() + assert_field_updated_in_history() + + +def check_standard_opening_times_updated(expected_value: list[dict], smoke_test_context: SmokeTestContext) -> None: + """Check that the standard opening times were updated in the services table and in service history. + + Args: + expected_value (list[dict]): The expected value of the standard opening times + smoke_test_context (SmokeTestContext): The smoke test context + """ + + def assert_field_updated() -> None: + response = invoke_dos_db_handler_lambda( + {"type": "change_event_standard_opening_times", "service_id": service_id}, + ) + response = loads(response) + expected_opening_periods = [] + for day, values in response.items(): + expected_opening_periods.extend( + { + "day": day, + "open": opening_period["start_time"], + "close": opening_period["end_time"], + "open_or_closed": True, + } + for opening_period in values + ) + assert expected_opening_periods == expected_value, ( + "Standard opening times were not updated - " + f"expected: '{expected_value}', actual: '{expected_opening_periods}'" + ) + + def assert_field_updated_in_history() -> None: + history = get_service_history(service_id) + first_key = next(iter(history.keys())) + new_history = history[first_key]["new"] + + for expected_value_time_periods in expected_value: + cms_key = f"cmsopentime{expected_value_time_periods['day'].lower()}" + open_seconds = seconds_since_midnight( + datetime.strptime(expected_value_time_periods["open"], "%H:%M").time(), + ) + close_seconds = seconds_since_midnight( + datetime.strptime(expected_value_time_periods["close"], "%H:%M").time(), + ) + seconds_str = f"{open_seconds}-{close_seconds}" + assert ( + seconds_str in new_history[cms_key]["data"]["add"] + ), f"Expected data: {seconds_str}, Actual data: {new_history[cms_key]['data']['add']}" + + if not smoke_test_context.blank_opening_times: + service_id = get_main_service_id_for_ods_code(DEFAULT_ODS_CODE) + assert_field_updated() + assert_field_updated_in_history() + + +def check_specified_opening_times_updated(expected_value: list[dict]) -> None: + """Check that the standard opening times were updated in the services table and in service history. + + Args: + expected_value (list[dict]): The expected value of the standard opening times + """ + + def assert_field_updated() -> None: + response = invoke_dos_db_handler_lambda( + {"type": "change_event_specified_opening_times", "service_id": service_id}, + ) + response = loads(response) + expected_opening_periods = [] + for date_str, values in response.items(): + expected_opening_periods.extend( + { + "date": datetime.strptime(date_str, "%Y-%m-%d").date(), + "open": specified_opening_date["start_time"], + "close": specified_opening_date["end_time"], + "open_or_closed": True, + } + for specified_opening_date in values + ) + assert expected_opening_periods == expected_value, ( + "Standard opening times were not updated - " + f"expected: '{expected_value}', actual: '{expected_opening_periods}'" + ) + + def assert_field_updated_in_history() -> None: + history = get_service_history(service_id) + first_key = next(iter(history.keys())) + specified_opening_times_key = "cmsopentimespecified" + new_history = history[first_key]["new"] + expected_specified_opening_times = [ + ( + f'{value["date"]}-' + f'{seconds_since_midnight(datetime.strptime(value["open"], "%H:%M").time())}-' + f'{seconds_since_midnight(datetime.strptime(value["close"], "%H:%M").time())}' + ) + for value in expected_value + ] + assert expected_specified_opening_times == new_history[specified_opening_times_key]["data"]["add"], ( + f"Expected data: {expected_specified_opening_times}," + f" Actual data: {new_history[specified_opening_times_key]['data']['add']}" + ) + + service_id = get_main_service_id_for_ods_code(DEFAULT_ODS_CODE) + assert_field_updated() + if expected_value: + assert_field_updated_in_history() + + +def check_blood_pressure_updated(expected_value: bool) -> None: + """Check that the blood pressure status was updated in the services table and in service history. + + Args: + expected_value (bool): The expected value of the blood pressure status + """ + service_id = get_service_id_for_ods_code_with_type_id(DEFAULT_ODS_CODE, DOS_BLOOD_PRESSURE_TYPE_ID) + check_service_status_updated(expected_value, service_id) + check_service_status_history_updated(expected_value, service_id) + + +def check_contraception_updated(expected_value: bool) -> None: + """Check that the contraception status was updated in the services table and in service history. + + Args: + expected_value (bool): The expected value of the contraception status + """ + service_id = get_service_id_for_ods_code_with_type_id(DEFAULT_ODS_CODE, DOS_CONTRACEPTION_TYPE_ID) + check_service_status_updated(expected_value, service_id) + check_service_status_history_updated(expected_value, service_id) + + +def check_service_status_updated(expected_value: bool, service_id: int) -> None: + """Check that the service status was updated in the services table. + + Args: + expected_value (bool): The expected value of the service status + service_id (int): The service ID to check the service status for + """ + actual_status = get_service_status(service_id) + assert ( + actual_status == expected_value + ), f"Service status was not updated - expected: '{expected_value}', actual: '{actual_status}'" + + +def check_service_status_history_updated(expected_value: bool, service_id: int) -> None: + """Check that the service status was updated in service history. + + Args: + expected_value (bool): The expected value of the service status + service_id (int): The service ID to check the service status for + """ + history = get_service_history(service_id) + first_key = next(iter(history.keys())) + new_history = history[first_key]["new"] + expected_value_name = "active" if expected_value else "closed" + assert ( + expected_value_name == new_history["cmsorgstatus"]["data"] + ), f"Expected data: {expected_value_name}, Actual data: {new_history['cmsorgstatus']['data']}" diff --git a/test/smoke/functions/smoke_test_context.py b/test/smoke/functions/smoke_test_context.py new file mode 100644 index 000000000..08815d9b8 --- /dev/null +++ b/test/smoke/functions/smoke_test_context.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from datetime import datetime + +from .change_event import ChangeEvent + + +@dataclass(repr=True) +class SmokeTestContext: + """Context for smoke tests.""" + + original_service: ChangeEvent | None = None + updated_service: ChangeEvent | None = None + request_start_time: datetime | None = None + blank_opening_times: bool = False diff --git a/test/smoke/functions/types.py b/test/smoke/functions/types.py new file mode 100644 index 000000000..372d71000 --- /dev/null +++ b/test/smoke/functions/types.py @@ -0,0 +1,9 @@ +from typing import TypedDict + + +class Demographics(TypedDict): + """Class to represent the demographics of a service.""" + + address: str + website: str + phone: str diff --git a/test/smoke/functions/utilities.py b/test/smoke/functions/utilities.py new file mode 100644 index 000000000..252704243 --- /dev/null +++ b/test/smoke/functions/utilities.py @@ -0,0 +1,6 @@ +from datetime import time + + +def seconds_since_midnight(time: time) -> int: + """Returns the number of seconds since midnight for the given time.""" + return time.hour * 60 * 60 + time.minute * 60 + time.second diff --git a/test/smoke/results/.gitignore b/test/smoke/results/.gitignore new file mode 100644 index 000000000..21b620503 --- /dev/null +++ b/test/smoke/results/.gitignore @@ -0,0 +1 @@ +testresults.json diff --git a/test/smoke/smoke.feature b/test/smoke/smoke.feature new file mode 100644 index 000000000..6289318ab --- /dev/null +++ b/test/smoke/smoke.feature @@ -0,0 +1,12 @@ +Feature: Production Smoke Test + + Scenario: Smoke Test + Given I create a new change event matching DoS + And I make changes to the change event + When I run the smoke test + Then I should see an update to DoS + And I should see data matching the updated service in DoS + Given I want to reset the change event + When I run the smoke test + Then I should see an update to DoS + And I should see data matching the original service in DoS diff --git a/test/smoke/test_steps.py b/test/smoke/test_steps.py new file mode 100644 index 000000000..22d92b358 --- /dev/null +++ b/test/smoke/test_steps.py @@ -0,0 +1,198 @@ +from datetime import datetime +from re import sub + +import pytest +from faker import Faker +from pytest_bdd import given, scenarios, then, when +from pytz import timezone + +from .functions.change_event import ChangeEvent +from .functions.change_event_request import send_change_event +from .functions.service import ( + check_blood_pressure_updated, + check_contraception_updated, + check_demographic_field_updated, + check_specified_opening_times_updated, + check_standard_opening_times_updated, + get_change_event_for_service, + wait_for_service_update, +) +from .functions.smoke_test_context import SmokeTestContext + +scenarios("smoke.feature") +FAKER = Faker("en_GB") + + +@pytest.fixture() +def smoke_test_context() -> SmokeTestContext: + """Set up the smoke test context.""" + return SmokeTestContext() + + +@given("I create a new change event matching DoS", target_fixture="smoke_test_context") +def _(smoke_test_context: SmokeTestContext) -> SmokeTestContext: + """Create a new change event matching DoS. + + Args: + smoke_test_context (SmokeTestContext): The smoke test context + + Returns: + SmokeTestContext: The smoke test context + """ + smoke_test_context.original_service = get_change_event_for_service("FDL64") + smoke_test_context.updated_service = ChangeEvent( + address=smoke_test_context.original_service.address, + website=smoke_test_context.original_service.website, + phone=smoke_test_context.original_service.phone, + standard_opening_times=smoke_test_context.original_service.standard_opening_times, + specified_opening_times=smoke_test_context.original_service.specified_opening_times, + blood_pressure=smoke_test_context.original_service.blood_pressure, + contraception=smoke_test_context.original_service.contraception, + ) + return smoke_test_context + + +@given("I make changes to the change event", target_fixture="smoke_test_context") +def _(smoke_test_context: SmokeTestContext) -> SmokeTestContext: + """Update the change event field to new value. + + Args: + smoke_test_context (SmokeTestContext): The smoke test context + + Returns: + SmokeTestContext: The smoke test context + """ + + def update_address() -> None: + new_address = f"{FAKER.street_address()}${FAKER.city()}" + new_address = sub(r"[A-Za-z]+('[A-Za-z]+)?", lambda word: word.group(0).capitalize(), new_address) + new_address = new_address.replace("\n", "$") + new_address = new_address.replace("'", "") + new_address = new_address.replace("&", "and") + smoke_test_context.updated_service.address = new_address + + def update_website() -> None: + smoke_test_context.updated_service.website = FAKER.url() + + def update_phone() -> None: + smoke_test_context.updated_service.phone = FAKER.phone_number().replace(" ", "") + + def update_standard_opening_times() -> None: + smoke_test_context.updated_service.standard_opening_times = [ + { + "day": FAKER.day_of_week(), + "open": "00:01", + "close": FAKER.time(pattern="%H:%M", end_datetime=None), + "open_or_closed": True, + }, + ] + + def update_specified_opening_times() -> None: + smoke_test_context.updated_service.specified_opening_times = [ + { + "date": FAKER.date_this_decade(before_today=False, after_today=True), + "open": "09:00", + "close": "17:00", + "open_or_closed": True, + }, + ] + + def update_blood_pressure() -> None: + smoke_test_context.updated_service.blood_pressure = not smoke_test_context.updated_service.blood_pressure + + def update_contraception() -> None: + smoke_test_context.updated_service.contraception = not smoke_test_context.updated_service.contraception + + update_address() + update_website() + update_phone() + update_standard_opening_times() + update_specified_opening_times() + update_blood_pressure() + update_contraception() + + return smoke_test_context + + +@given("I want to reset the change event", target_fixture="smoke_test_context") +def _(smoke_test_context: SmokeTestContext) -> SmokeTestContext: + """Reset the change event to the original value. + + Args: + smoke_test_context (SmokeTestContext): The smoke test context + + Returns: + SmokeTestContext: The smoke test context + """ + if not smoke_test_context.original_service.standard_opening_times: + smoke_test_context.blank_opening_times = True + smoke_test_context.original_service.standard_opening_times = ( + smoke_test_context.updated_service.standard_opening_times + ) + smoke_test_context.updated_service = smoke_test_context.original_service + return smoke_test_context + + +@when("I run the smoke test", target_fixture="smoke_test_context") +def _(smoke_test_context: SmokeTestContext) -> SmokeTestContext: + """Run the smoke test. + + Args: + smoke_test_context (SmokeTestContext): The smoke test context + + Returns: + SmokeTestContext: The smoke test context + """ + smoke_test_context.request_start_time = datetime.now(tz=timezone("Europe/London")) + change_event_json = smoke_test_context.updated_service.create_change_event() + send_change_event(change_event_json) + return smoke_test_context + + +@then("I should see an update to DoS", target_fixture="smoke_test_context") +def _(smoke_test_context: SmokeTestContext) -> SmokeTestContext: + """Check the DoS service has been updated. + + Args: + smoke_test_context (SmokeTestContext): The smoke test context + + Returns: + SmokeTestContext: The smoke test context + """ + wait_for_service_update(smoke_test_context.request_start_time) + return smoke_test_context + + +@then("I should see data matching the updated service in DoS") +@then("I should see data matching the original service in DoS") +def _(smoke_test_context: SmokeTestContext) -> None: + """Check the DoS service has been updated to match the original service. + + Args: + smoke_test_context (SmokeTestContext): The smoke test context + + Returns: + SmokeTestContext: The smoke test context + """ + check_demographic_field_updated( + field="address", + service_history_key="postaladdress", + expected_value=smoke_test_context.updated_service.address, + ) + check_demographic_field_updated( + field="web", + service_history_key="cmsurl", + expected_value=smoke_test_context.updated_service.website, + ) + check_demographic_field_updated( + field="publicphone", + service_history_key="cmstelephoneno", + expected_value=smoke_test_context.updated_service.phone, + ) + check_standard_opening_times_updated( + expected_value=smoke_test_context.updated_service.standard_opening_times, + smoke_test_context=smoke_test_context, + ) + check_specified_opening_times_updated(expected_value=smoke_test_context.updated_service.specified_opening_times) + check_blood_pressure_updated(expected_value=smoke_test_context.updated_service.blood_pressure) + check_contraception_updated(expected_value=smoke_test_context.updated_service.contraception)