From f9c77790a04f021c728077aa01cf6d43f0244b1c Mon Sep 17 00:00:00 2001 From: Seun Ogunbiyi <91503321+Tiny49@users.noreply.github.com> Date: Thu, 18 Jul 2024 11:16:42 +0100 Subject: [PATCH 1/2] FS-4528: Build and Deploy to AWS Environments. (#7) * Added new services and the code * edited the manifest file * edited * edited the files * edited the flag * fix unit tests * default db url * added the latest image --------- Co-authored-by: Sarah Sloan --- .github/PULL_REQUEST_TEMPLATE.md | 16 ++ .github/dependabot.yml | 16 ++ .github/workflows/codeql-analysis.yml | 75 +++++++ .github/workflows/copilot_deploy.yml | 161 +++++++++++++++ config/envs/unit_test.py | 5 +- copilot/.workspace | 1 + .../addons/fsd-self-serve-cluster.yml | 183 ++++++++++++++++++ copilot/fsd-self-serve/manifest.yml | 109 +++++++++++ 8 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/copilot_deploy.yml create mode 100644 copilot/.workspace create mode 100644 copilot/fsd-self-serve/addons/fsd-self-serve-cluster.yml create mode 100644 copilot/fsd-self-serve/manifest.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..960d2fa --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +_Add ticket reference to Pull Request title: e.g. 'FS-123: Add content', if there is no ticket prefix with BAU_ + + +### Change description +_A brief description of the pull request_ + +- [ ] Unit tests and other appropriate tests added or updated +- [ ] README and other documentation has been updated / added (if needed) +- [ ] Commit messages are meaningful and follow good commit message guidelines (e.g. "FS-XXXX: Add margin to nav items preventing overlapping of logo") + + +### How to test +_If manual testing is needed, give suggested testing steps_ + + +### Screenshots of UI changes (if applicable) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..759ba39 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + pull-request-branch-name: + separator: "-" + rebase-strategy: "auto" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + pull-request-branch-name: + separator: "-" + rebase-strategy: "auto" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..911728b --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,75 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + paths-ignore: + - "**/README.md" + branches: [ main ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ main ] + paths-ignore: + - "**/README.md" + + schedule: + - cron: '44 9 * * 2' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://git.io/codeql-language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/copilot_deploy.yml b/.github/workflows/copilot_deploy.yml new file mode 100644 index 0000000..769f6e5 --- /dev/null +++ b/.github/workflows/copilot_deploy.yml @@ -0,0 +1,161 @@ +name: Deploy to AWS +run-name: AWS Deploy ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || (github.ref == 'refs/heads/main' && 'Dev-Test-UAT-Prod' || 'Dev') }} + +on: + workflow_dispatch: + inputs: + environment: + description: Which AWS Account to use + type: choice + required: true + options: + - dev + - test + - uat + - prod + run_performance_tests: + required: false + default: false + type: boolean + description: Run performance tests + run_e2e_tests_assessment: + required: false + default: false + type: boolean + description: Run e2e tests (assessment) + run_e2e_tests_application: + required: false + default: true + type: boolean + description: Run e2e tests (application) + push: + # Ignore README markdown and the docs folder + # Only automatically deploy when something in the app or tests folder has changed + paths: + - '!**/README.md' + - '!docs/**' + - 'app/**' + - 'config/**' + - 'tests/**' + - 'requirements-dev.in' + - 'requirements-dev.txt' + - 'requirements.in' + - 'requirements.txt' + - '.github/workflows/copilot_deploy.yml' + +jobs: + setup: + uses: communitiesuk/funding-service-design-workflows/.github/workflows/determine-jobs.yml@main + with: + environment: ${{ inputs.environment }} + + pre_deploy_tests: + uses: communitiesuk/funding-service-design-workflows/.github/workflows/pre-deploy.yml@main + with: + postgres_unit_testing: true + db_name: fab_unit_test + + paketo_build: + needs: [ setup ] + permissions: + packages: write + uses: communitiesuk/funding-service-design-workflows/.github/workflows/package.yml@main + with: + version_to_build: sha-${{ github.sha }} + owner: ${{ github.repository_owner }} + application: funding-service-design-self-serve + assets_required: false + + dev_deploy: + needs: [ pre_deploy_tests, paketo_build, setup ] + if: ${{ contains(fromJSON(needs.setup.outputs.jobs_to_run), 'dev') }} + uses: communitiesuk/funding-service-design-workflows/.github/workflows/standard-deploy.yml@main + secrets: + AWS_ACCOUNT: ${{ secrets.AWS_ACCOUNT }} + with: + environment: dev + app_name: self-serve + version: sha-${{ github.sha }} + + post_dev_deploy_tests: + needs: dev_deploy + concurrency: + group: 'fsd-preaward-dev' + cancel-in-progress: false + secrets: + FSD_GH_APP_ID: ${{ secrets.FSD_GH_APP_ID }} + FSD_GH_APP_KEY: ${{ secrets.FSD_GH_APP_KEY }} + uses: communitiesuk/funding-service-design-workflows/.github/workflows/post-deploy.yml@main + with: + run_performance_tests: ${{ inputs.run_performance_tests || true }} + run_e2e_tests_assessment: ${{ inputs.run_e2e_tests_assessment || false }} + run_e2e_tests_application: ${{ inputs.run_e2e_tests_application || false }} + app_name: self-serve + environment: dev + + test_deploy: + needs: [ dev_deploy, post_dev_deploy_tests, paketo_build, setup ] + if: ${{ always() && contains(fromJSON(needs.setup.outputs.jobs_to_run), 'test') && (! contains(needs.*.result, 'failure') ) && (! contains(needs.*.result, 'cancelled') )}} + uses: communitiesuk/funding-service-design-workflows/.github/workflows/standard-deploy.yml@main + secrets: + AWS_ACCOUNT: ${{ secrets.AWS_ACCOUNT }} + with: + environment: test + app_name: self-serve + version: sha-${{ github.sha }} + + post_test_deploy_tests: + needs: test_deploy + if: ${{ always() && contains(fromJSON(needs.setup.outputs.jobs_to_run), 'test') && (! contains(needs.*.result, 'failure') ) && (! contains(needs.*.result, 'cancelled') )}} + concurrency: + group: 'fsd-preaward-test' + cancel-in-progress: false + secrets: + FSD_GH_APP_ID: ${{ secrets.FSD_GH_APP_ID }} + FSD_GH_APP_KEY: ${{ secrets.FSD_GH_APP_KEY }} + uses: communitiesuk/funding-service-design-workflows/.github/workflows/post-deploy.yml@main + with: + run_performance_tests: ${{ inputs.run_performance_tests || false }} + run_e2e_tests_assessment: ${{ inputs.run_e2e_tests_assessment || false }} + run_e2e_tests_application: ${{ inputs.run_e2e_tests_application || true }} + app_name: self-serve + environment: test + + uat_deploy: + needs: [ dev_deploy, post_dev_deploy_tests, test_deploy, post_test_deploy_tests, paketo_build, setup ] + if: ${{ always() && contains(fromJSON(needs.setup.outputs.jobs_to_run), 'uat') && (! contains(needs.*.result, 'failure') ) && (! contains(needs.*.result, 'cancelled') )}} + uses: communitiesuk/funding-service-design-workflows/.github/workflows/standard-deploy.yml@main + secrets: + AWS_ACCOUNT: ${{ secrets.AWS_ACCOUNT }} + with: + environment: uat + app_name: self-serve + version: sha-${{ github.sha }} + + post_uat_deploy_tests: + needs: uat_deploy + if: ${{ always() && contains(fromJSON(needs.setup.outputs.jobs_to_run), 'uat') && (! contains(needs.*.result, 'failure') ) && (! contains(needs.*.result, 'cancelled') )}} + concurrency: + group: 'fsd-preaward-uat' + cancel-in-progress: false + secrets: + FSD_GH_APP_ID: ${{ secrets.FSD_GH_APP_ID }} + FSD_GH_APP_KEY: ${{ secrets.FSD_GH_APP_KEY }} + uses: communitiesuk/funding-service-design-workflows/.github/workflows/post-deploy.yml@main + with: + run_performance_tests: ${{ inputs.run_performance_tests || false }} + run_e2e_tests_assessment: ${{ inputs.run_e2e_tests_assessment || false }} + run_e2e_tests_application: ${{ inputs.run_e2e_tests_application || true }} + app_name: self-serve + environment: uat + + prod_deploy: + needs: [ dev_deploy, post_dev_deploy_tests, test_deploy, post_test_deploy_tests, uat_deploy, post_uat_deploy_tests, paketo_build, setup ] + if: ${{ always() && contains(fromJSON(needs.setup.outputs.jobs_to_run), 'prod') && (! contains(needs.*.result, 'failure') ) && (! contains(needs.*.result, 'cancelled') )}} + uses: communitiesuk/funding-service-design-workflows/.github/workflows/standard-deploy.yml@main + secrets: + AWS_ACCOUNT: ${{ secrets.AWS_ACCOUNT }} + with: + environment: prod + app_name: self-serve + version: sha-${{ github.sha }} diff --git a/config/envs/unit_test.py b/config/envs/unit_test.py index 0703936..526846e 100644 --- a/config/envs/unit_test.py +++ b/config/envs/unit_test.py @@ -1,4 +1,5 @@ import logging +from os import getenv from fsd_utils import configclass @@ -11,4 +12,6 @@ class UnitTestConfig(Config): # Logging FSD_LOG_LEVEL = logging.DEBUG - SQLALCHEMY_DATABASE_URI = "postgresql://postgres:password@fab-db:5432/fab_unit_test" # pragma: allowlist secret + SQLALCHEMY_DATABASE_URI = getenv( + "DATABASE_URL", "postgresql://postgres:postgres@127.0.0.1:5432/fab_unit_test" # pragma: allowlist secret + ) diff --git a/copilot/.workspace b/copilot/.workspace new file mode 100644 index 0000000..92b2058 --- /dev/null +++ b/copilot/.workspace @@ -0,0 +1 @@ +application: pre-award diff --git a/copilot/fsd-self-serve/addons/fsd-self-serve-cluster.yml b/copilot/fsd-self-serve/addons/fsd-self-serve-cluster.yml new file mode 100644 index 0000000..7d8b7b0 --- /dev/null +++ b/copilot/fsd-self-serve/addons/fsd-self-serve-cluster.yml @@ -0,0 +1,183 @@ +Parameters: + App: + Type: String + Description: Your application's name. + Env: + Type: String + Description: The environment name your service, job, or workflow is being deployed to. + Name: + Type: String + Description: The name of the service, job, or workflow being deployed. + # Customize your Aurora Serverless cluster by setting the default value of the following parameters. + fsdselfserveclusterDBName: + Type: String + Description: The name of the initial database to be created in the Aurora Serverless v2 cluster. + Default: fsd_self_serve + # Cannot have special characters + # Naming constraints: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_Limits.html#RDS_Limits.Constraints +Mappings: + fsdselfserveclusterEnvScalingConfigurationMap: + All: + "DBMinCapacity": 0.5 # AllowedValues: from 0.5 through 128 + "DBMaxCapacity": 8 # AllowedValues: from 0.5 through 128 + BastionMap: + dev: + "SecurityGroup": "sg-0b6c7aabb95bf14a9" + test: + "SecurityGroup": "sg-0cf75a004dbade7b8" + uat: + "SecurityGroup": "sg-04017abfef2079894" + prod: + "SecurityGroup": "sg-08cecea8f9b8a4ec9" + +Resources: + fsdselfserveclusterDBSubnetGroup: + Type: 'AWS::RDS::DBSubnetGroup' + Properties: + DBSubnetGroupDescription: Group of Copilot private subnets for Aurora Serverless v2 cluster. + SubnetIds: + !Split [',', { 'Fn::ImportValue': !Sub '${App}-${Env}-PrivateSubnets' }] + fsdselfserveclusterSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for your workload to access the Aurora Serverless v2 cluster fsdselfservecluster' + Type: 'AWS::EC2::SecurityGroup' + Properties: + GroupDescription: !Sub 'The Security Group for ${Name} to access Aurora Serverless v2 cluster fsdselfservecluster.' + VpcId: + Fn::ImportValue: + !Sub '${App}-${Env}-VpcId' + Tags: + - Key: Name + Value: !Sub 'copilot-${App}-${Env}-${Name}-Aurora' + fsdselfserveclusterDBClusterSecurityGroup: + Metadata: + 'aws:copilot:description': 'A security group for your Aurora Serverless v2 cluster fsdselfservecluster' + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: The Security Group for the Aurora Serverless v2 cluster. + SecurityGroupIngress: + - ToPort: 5432 + FromPort: 5432 + IpProtocol: tcp + Description: !Sub 'From the Aurora Security Group of the workload ${Name}.' + SourceSecurityGroupId: !Ref fsdselfserveclusterSecurityGroup + - ToPort: 5432 + FromPort: 5432 + IpProtocol: tcp + Description: !Sub 'From the Bastion Security Group.' + SourceSecurityGroupId: !FindInMap [BastionMap, !Ref Env, 'SecurityGroup'] + VpcId: + Fn::ImportValue: + !Sub '${App}-${Env}-VpcId' + Tags: + - Key: Name + Value: !Sub 'copilot-${App}-${Env}-${Name}-Aurora' + fsdselfserveclusterAuroraSecret: + Metadata: + 'aws:copilot:description': 'A Secrets Manager secret to store your DB credentials' + Type: AWS::SecretsManager::Secret + Properties: + Description: !Sub Aurora main user secret for ${AWS::StackName} + GenerateSecretString: + SecretStringTemplate: '{"username": "postgres"}' + GenerateStringKey: "password" + ExcludePunctuation: true + IncludeSpace: false + PasswordLength: 16 + fsdselfserveclusterDBClusterParameterGroup: + Metadata: + 'aws:copilot:description': 'A DB parameter group for engine configuration values' + Type: 'AWS::RDS::DBClusterParameterGroup' + Properties: + Description: !Ref 'AWS::StackName' + Family: 'aurora-postgresql14' + Parameters: + client_encoding: 'UTF8' + fsdselfserveclusterDBCluster: + Metadata: + 'aws:copilot:description': 'The fsdselfservecluster Aurora Serverless v2 database cluster' + Type: 'AWS::RDS::DBCluster' + Properties: + MasterUsername: + !Join [ "", [ '{{resolve:secretsmanager:', !Ref fsdselfserveclusterAuroraSecret, ":SecretString:username}}" ]] # pragma: allowlist secret + MasterUserPassword: + !Join [ "", [ '{{resolve:secretsmanager:', !Ref fsdselfserveclusterAuroraSecret, ":SecretString:password}}" ]] # pragma: allowlist secret + DatabaseName: !Ref fsdselfserveclusterDBName + Engine: 'aurora-postgresql' + EngineVersion: '14.4' + DBClusterParameterGroupName: !Ref fsdselfserveclusterDBClusterParameterGroup + DBSubnetGroupName: !Ref fsdselfserveclusterDBSubnetGroup + Port: 5432 + StorageEncrypted: true + BackupRetentionPeriod: 8 + VpcSecurityGroupIds: + - !Ref fsdselfserveclusterDBClusterSecurityGroup + ServerlessV2ScalingConfiguration: + # Replace "All" below with "!Ref Env" to set different autoscaling limits per environment. + MinCapacity: !FindInMap [fsdselfserveclusterEnvScalingConfigurationMap, All, DBMinCapacity] + MaxCapacity: !FindInMap [fsdselfserveclusterEnvScalingConfigurationMap, All, DBMaxCapacity] + fsdselfserveclusterDBWriterInstance: + Metadata: + 'aws:copilot:description': 'The fsdselfservecluster Aurora Serverless v2 writer instance' + Type: 'AWS::RDS::DBInstance' + Properties: + DBClusterIdentifier: !Ref fsdselfserveclusterDBCluster + DBInstanceClass: db.serverless + Engine: 'aurora-postgresql' + PromotionTier: 1 + AvailabilityZone: !Select + - 0 + - !GetAZs + Ref: AWS::Region + + fsdselfserveclusterSecretAuroraClusterAttachment: + Type: AWS::SecretsManager::SecretTargetAttachment + Properties: + SecretId: !Ref fsdselfserveclusterAuroraSecret + TargetId: !Ref fsdselfserveclusterDBCluster + TargetType: AWS::RDS::DBCluster + + FormRunnerFormUploadsBucketAccessPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: S3FormUploadBucketActions + Effect: Allow + Action: + - s3:Get* + - s3:List* + - s3:Describe* + - s3:PutObject + - s3:PutObjectACL + - s3:DeleteObject + - s3:ReplicateObject + Resource: + - Fn::ImportValue: !Sub ${App}-${Env}-FormUploadsBucketARN + - !Sub + - "${FullBucketARN}/*" + - FullBucketARN: + Fn::ImportValue: !Sub "${App}-${Env}-FormUploadsBucketARN" + +Outputs: + DatabaseUrl: + Description: "The URL of this database." + Value: + !Sub + - "postgres://${USERNAME}:${PASSWORD}@${HOSTNAME}:${PORT}/${DBNAME}" + - USERNAME: !Join [ "", [ '{{resolve:secretsmanager:', !Ref fsdselfserveclusterAuroraSecret, ":SecretString:username}}" ]] # pragma: allowlist secret + PASSWORD: !Join [ "", [ '{{resolve:secretsmanager:', !Ref fsdselfserveclusterAuroraSecret, ":SecretString:password}}" ]] # pragma: allowlist secret + HOSTNAME: !Join [ "", [ '{{resolve:secretsmanager:', !Ref fsdselfserveclusterAuroraSecret, ":SecretString:host}}" ]] # pragma: allowlist secret + PORT: !Join [ "", [ '{{resolve:secretsmanager:', !Ref fsdselfserveclusterAuroraSecret, ":SecretString:port}}" ]] # pragma: allowlist secret + DBNAME: !Join [ "", [ '{{resolve:secretsmanager:', !Ref fsdselfserveclusterAuroraSecret, ":SecretString:dbname}}" ]] # pragma: allowlist secret + + fsdselfserveclusterSecret: # injected as FSDselfserveCLUSTER_SECRET environment variable by Copilot. + Description: "The JSON secret that holds the database username and password. Fields are 'host', 'port', 'dbname', 'username', 'password', 'dbClusterIdentifier' and 'engine'" + Value: !Ref fsdselfserveclusterAuroraSecret + fsdselfserveclusterSecurityGroup: + Description: "The security group to attach to the workload." + Value: !Ref fsdselfserveclusterSecurityGroup + FormRunnerFormUploadsBucketAccessPolicyArn: + Description: "The ARN of the ManagedPolicy to attach to the task role." + Value: !Ref FormRunnerFormUploadsBucketAccessPolicy diff --git a/copilot/fsd-self-serve/manifest.yml b/copilot/fsd-self-serve/manifest.yml new file mode 100644 index 0000000..e583b78 --- /dev/null +++ b/copilot/fsd-self-serve/manifest.yml @@ -0,0 +1,109 @@ +# The manifest for the "fsd-assessment" service. +# Read the full specification for the "Load Balanced Web Service" type at: +# https://aws.github.io/copilot-cli/docs/manifest/lb-web-service/ + +# Your service name will be used in naming your resources like log groups, ECS services, etc. +name: fsd-self-serve +type: Load Balanced Web Service + +# Distribute traffic to your service. +http: + # Requests to this path will be forwarded to your service. + # To match all requests you can use the "/" path. + path: '/' + # You can specify a custom health check path. The default is "/". + healthcheck: '/healthcheck' + #alias: self-serve.${COPILOT_ENVIRONMENT_NAME}.access-funding.test.levellingup.gov.uk + +# Configuration for your containers and service. +image: + # Docker build arguments. For additional overrides: https://aws.github.io/copilot-cli/docs/manifest/lb-web-service/#image-location + location: ghcr.io/communitiesuk/funding-service-design-self-serve:sha-6cdb8baa686e0357be6b56730938bab7e433fd96 + # Port exposed through your container to route traffic to it. + port: 8080 + +# Valid values: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html +# Number of CPU units for the task. +cpu: 1024 +# Amount of memory in MiB used by the task. +memory: 2048 + +# See https://aws.github.io/copilot-cli/docs/manifest/lb-web-service/#platform +platform: linux/x86_64 +# Number of tasks that should be running in your service. +count: 2 +# Enable running commands in your container. +exec: true + +network: + connect: true # Enable Service Connect for intra-environment traffic between services. + +# storage: + # readonly_fs: true # Limit to read-only access to mounted root filesystems. + +# Optional fields for more advanced use-cases. +# +# Pass environment variables as key value pairs. +# variables: +# ACCOUNT_STORE_API_HOST: "http://fsd-account-store:8080" +# APPLICANT_self-serve_HOST: "https://self-serve.${COPILOT_ENVIRONMENT_NAME}.access-funding.test.levellingup.gov.uk" +# APPLICATION_STORE_API_HOST: "http://fsd-application-store:8080" +# AUTHENTICATOR_HOST: "https://authenticator.${COPILOT_ENVIRONMENT_NAME}.access-funding.test.levellingup.gov.uk" +# COOKIE_DOMAIN: ".test.levellingup.gov.uk" +# FLASK_ENV: ${COPILOT_ENVIRONMENT_NAME} +# FORMS_SERVICE_PUBLIC_HOST: "https://forms.${COPILOT_ENVIRONMENT_NAME}.access-funding.test.levellingup.gov.uk" +# FUND_STORE_API_HOST: "http://fsd-fund-store:8080" +# NOTIFICATION_SERVICE_HOST: http://fsd-notification:8080 +# REDIS_INSTANCE_URI: +# from_cfn: ${COPILOT_APPLICATION_NAME}-${COPILOT_ENVIRONMENT_NAME}-MagicLinksRedisInstanceURI +# SENTRY_DSN: "https://3c6d2fd1e3824aecb3826a7d640b29a9@o1432034.ingest.sentry.io/4503897449103360" +# MAINTENANCE_MODE: false + +secrets: + RSA256_PUBLIC_KEY_BASE64: /copilot/${COPILOT_APPLICATION_NAME}/${COPILOT_ENVIRONMENT_NAME}/secrets/RSA256_PUBLIC_KEY_BASE64 + +# You can override any of the values defined above by environment. +environments: + dev: + count: + spot: 1 + test: + deployment: + rolling: 'recreate' + count: + spot: 2 +# uat: +# http: +# alias: "self-serve.${COPILOT_ENVIRONMENT_NAME}.access-funding.test.levellingup.gov.uk" +# count: +# range: 2-4 +# cooldown: +# in: 60s +# out: 30s +# cpu_percentage: +# value: 70 +# memory_percentage: +# value: 80 +# requests: 30 +# response_time: 2s +# prod: +# http: +# alias: ["self-serve.prod.access-funding.levellingup.gov.uk", "self-serve.access-funding.levellingup.gov.uk"] +# hosted_zone: Z0686469NF3ZJTU9I02M +# variables: +# COOKIE_DOMAIN: ".levellingup.gov.uk" +# AUTHENTICATOR_HOST: "https://authenticator.access-funding.levellingup.gov.uk" +# APPLICANT_self-serve_HOST: "https://self-serve.access-funding.levellingup.gov.uk" +# ASSESSMENT_self-serve_HOST: "https://assessment.access-funding.levellingup.gov.uk" +# FORMS_SERVICE_PUBLIC_HOST: "https://forms.access-funding.levellingup.gov.uk" +# FLASK_ENV: production +# count: +# range: 2-4 +# cooldown: +# in: 60s +# out: 30s +# cpu_percentage: +# value: 70 +# memory_percentage: +# value: 80 +# requests: 30 From 6c73dd23be53f7158a5f01655b3065f5c79e74fd Mon Sep 17 00:00:00 2001 From: srh-sloan Date: Fri, 19 Jul 2024 08:59:53 +0100 Subject: [PATCH 2/2] Fs 4533 clone template (#5) * BAU renaming to FAB * adding is_template to list column and correctin data type for weighting * Adding unique index on template page display_path * fs-4533 clone component * adding is_template to list column and correctin data type for weighting * Adding unique index on template page display_path * fs-4533 clone component * fs-4533 clone page and components * adding is_template to list column and correctin data type for weighting * Adding unique index on template page display_path * fs-4533 clone component * fs-4533 clone page and components * Bau add utils (#6) * BAU updating to use config class in utils * remove warning * BAU enabling db test fixtures * review comments * adding is_template to list column and correctin data type for weighting * Adding unique index on template page display_path * fs-4533 clone component * fs-4533 clone page and components * Adding unique index on template page display_path * Adding unique index on template page display_path * fs-4533 clone page and components * FS-4528: Build and Deploy to AWS Environments. (#7) * Added new services and the code * edited the manifest file * edited * edited the files * edited the flag * fix unit tests * default db url * added the latest image --------- Co-authored-by: Sarah Sloan * updating tests to use utils fixtures * separate out unit test and ui test data * adding is_template to list column and correctin data type for weighting * Adding unique index on template page display_path * fs-4533 clone component * fs-4533 clone page and components * Adding unique index on template page display_path * Adding unique index on template page display_path * fs-4533 clone page and components * Adding unique index on template page display_path * fs-4533 clone page and components * Adding unique index on template page display_path * Adding unique index on template page display_path * fs-4533 clone page and components * updating tests to use utils fixtures * separate out unit test and ui test data * adding test for page with conditions --------- Co-authored-by: Seun Ogunbiyi <91503321+Tiny49@users.noreply.github.com> --- .vscode/settings.json | 8 +- app/blueprints/fund_builder/routes.py | 2 +- .../templates/view_application_config.html | 6 +- .../~2024_07_16_0850-eb66c0e87001_.py | 32 ++ .../~2024_07_16_0921-e58b1e674060_.py | 33 ++ .../~2024_07_17_1202-5c63de4e4e49_.py | 58 ++++ app/db/models/application_config.py | 69 ++-- app/db/models/assessment_config.py | 4 +- app/db/queries/application.py | 70 ++++ app/db/queries/fund.py | 9 +- app/db/queries/round.py | 3 +- app/question_reuse/generate_form.py | 8 +- config/envs/unit_test.py | 3 +- docker-compose.yml | 1 + tasks/db_tasks.py | 3 +- tasks/test_data.py | 173 ++++++++-- tests/conftest.py | 4 +- tests/test_build_forms.py | 2 +- tests/test_clone.py | 326 ++++++++++++++++++ tests/test_db.py | 170 ++++++--- tests/test_generate_form.py | 14 +- tests/test_integration.py | 167 ++++++++- tests/unit_test_data.py | 8 +- 23 files changed, 1017 insertions(+), 156 deletions(-) create mode 100644 app/db/migrations/versions/~2024_07_16_0850-eb66c0e87001_.py create mode 100644 app/db/migrations/versions/~2024_07_16_0921-e58b1e674060_.py create mode 100644 app/db/migrations/versions/~2024_07_17_1202-5c63de4e4e49_.py create mode 100644 tests/test_clone.py diff --git a/.vscode/settings.json b/.vscode/settings.json index a049ef9..786b3e0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,11 @@ "tests" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "editor.quickSuggestions": { + "other": true, + "comments": false, + "strings": false + }, + "editor.quickSuggestionsDelay": 10, } diff --git a/app/blueprints/fund_builder/routes.py b/app/blueprints/fund_builder/routes.py index 12e7767..dd6ab4b 100644 --- a/app/blueprints/fund_builder/routes.py +++ b/app/blueprints/fund_builder/routes.py @@ -189,7 +189,7 @@ def view_all_questions(round_id): section_data = [] for section in sections_in_round: forms = [{"name": form.runner_publish_name, "form_data": build_form_json(form)} for form in section.forms] - section_data.append({"section_title": section.name_in_apply["en"], "forms": forms}) + section_data.append({"section_title": section.name_in_apply_json["en"], "forms": forms}) print_data = generate_print_data_for_sections( section_data, diff --git a/app/blueprints/fund_builder/templates/view_application_config.html b/app/blueprints/fund_builder/templates/view_application_config.html index 7f53414..3075cc8 100644 --- a/app/blueprints/fund_builder/templates/view_application_config.html +++ b/app/blueprints/fund_builder/templates/view_application_config.html @@ -19,9 +19,9 @@

{{ fund.short_name }} - {{ round.short_name }}

{% for section in round.sections %}
-

{{ section.index }}. {{ section.name_in_apply["en"] }}

+

{{ section.index }}. {{ section.name_in_apply_json["en"] }}

{% for form in section.forms %} -

Form {{ form.section_index }}. {{ form.name_in_apply["en"] }}

+

Form {{ form.section_index }}. {{ form.name_in_apply_json["en"] }}

{{ govukButton({ "text": "Preview Form", "href": url_for("build_fund_bp.preview_form", form_id=form.form_id), @@ -36,7 +36,7 @@

Form {{ form.section_index }}. {{ form.name_in_apply }} {% for page in form.pages %}
- {{ page.form_index }}. {{ page.name_in_apply["en"] }} + {{ page.form_index }}. {{ page.name_in_apply_json["en"] }}
    {% for component in page.components %} {% set list_details %} diff --git a/app/db/migrations/versions/~2024_07_16_0850-eb66c0e87001_.py b/app/db/migrations/versions/~2024_07_16_0850-eb66c0e87001_.py new file mode 100644 index 0000000..0fe06c9 --- /dev/null +++ b/app/db/migrations/versions/~2024_07_16_0850-eb66c0e87001_.py @@ -0,0 +1,32 @@ +"""Adding is_template to list table + +Revision ID: eb66c0e87001 +Revises: 4247b8f71cff +Create Date: 2024-07-16 08:50:01.637018 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "eb66c0e87001" +down_revision = "4247b8f71cff" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("lizt", schema=None) as batch_op: + batch_op.add_column(sa.Column("is_template", sa.Boolean(), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("lizt", schema=None) as batch_op: + batch_op.drop_column("is_template") + + # ### end Alembic commands ### diff --git a/app/db/migrations/versions/~2024_07_16_0921-e58b1e674060_.py b/app/db/migrations/versions/~2024_07_16_0921-e58b1e674060_.py new file mode 100644 index 0000000..d3f6567 --- /dev/null +++ b/app/db/migrations/versions/~2024_07_16_0921-e58b1e674060_.py @@ -0,0 +1,33 @@ +"""Add unique index on Page.display_path when is_template is true + +Revision ID: e58b1e674060 +Revises: eb66c0e87001 +Create Date: 2024-07-16 09:21:52.877957 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e58b1e674060" +down_revision = "eb66c0e87001" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("page", schema=None) as batch_op: + batch_op.create_index( + "ix_template_page_name", ["display_path"], unique=True, postgresql_where="Page.is_template = true" + ) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("page", schema=None) as batch_op: + batch_op.drop_index("ix_template_page_name", postgresql_where="Page.is_template = true") + + # ### end Alembic commands ### diff --git a/app/db/migrations/versions/~2024_07_17_1202-5c63de4e4e49_.py b/app/db/migrations/versions/~2024_07_17_1202-5c63de4e4e49_.py new file mode 100644 index 0000000..2a3ea98 --- /dev/null +++ b/app/db/migrations/versions/~2024_07_17_1202-5c63de4e4e49_.py @@ -0,0 +1,58 @@ +"""Rename template name columns + +Revision ID: 5c63de4e4e49 +Revises: e58b1e674060 +Create Date: 2024-07-17 12:02:11.799787 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5c63de4e4e49" +down_revision = "e58b1e674060" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("component", schema=None) as batch_op: + batch_op.add_column(sa.Column("template_name", sa.String(), nullable=True)) + batch_op.drop_column("Template Name") + + with op.batch_alter_table("form", schema=None) as batch_op: + batch_op.add_column(sa.Column("template_name", sa.String(), nullable=True)) + batch_op.drop_column("Template Name") + + with op.batch_alter_table("page", schema=None) as batch_op: + batch_op.add_column(sa.Column("template_name", sa.String(), nullable=True)) + batch_op.drop_column("Template Name") + + with op.batch_alter_table("section", schema=None) as batch_op: + batch_op.add_column(sa.Column("template_name", sa.String(), nullable=True)) + batch_op.drop_column("Template Name") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("section", schema=None) as batch_op: + batch_op.add_column(sa.Column("Template Name", sa.VARCHAR(), autoincrement=False, nullable=True)) + batch_op.drop_column("template_name") + + with op.batch_alter_table("page", schema=None) as batch_op: + batch_op.add_column(sa.Column("Template Name", sa.VARCHAR(), autoincrement=False, nullable=True)) + batch_op.drop_column("template_name") + + with op.batch_alter_table("form", schema=None) as batch_op: + batch_op.add_column(sa.Column("Template Name", sa.VARCHAR(), autoincrement=False, nullable=True)) + batch_op.drop_column("template_name") + + with op.batch_alter_table("component", schema=None) as batch_op: + batch_op.add_column(sa.Column("Template Name", sa.VARCHAR(), autoincrement=False, nullable=True)) + batch_op.drop_column("template_name") + + # ### end Alembic commands ### diff --git a/app/db/models/application_config.py b/app/db/models/application_config.py index 7b66571..49a41a7 100644 --- a/app/db/models/application_config.py +++ b/app/db/models/application_config.py @@ -8,8 +8,10 @@ from flask_sqlalchemy.model import DefaultMeta from sqlalchemy import Column from sqlalchemy import ForeignKey +from sqlalchemy import Index from sqlalchemy import Integer from sqlalchemy import String +from sqlalchemy import inspect from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import UUID @@ -38,47 +40,49 @@ class ComponentType(Enum): class Section(BaseModel): round_id = Column( - "round_id", UUID(as_uuid=True), ForeignKey("round.round_id"), nullable=True, # will be null where this is a template and not linked to a round ) section_id = Column( - "section_id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, ) - name_in_apply = Column("name_in_apply_json", JSON(none_as_null=True), nullable=False, unique=False) - template_name = Column("Template Name", String(), nullable=True) - is_template = Column("is_template", Boolean, default=False, nullable=False) - audit_info = Column("audit_info", JSON(none_as_null=True)) + name_in_apply_json = Column(JSON(none_as_null=True), nullable=False, unique=False) + template_name = Column(String(), nullable=True) + is_template = Column(Boolean, default=False, nullable=False) + audit_info = Column(JSON(none_as_null=True)) forms: Mapped[List["Form"]] = relationship( "Form", order_by="Form.section_index", collection_class=ordering_list("section_index") ) index = Column(Integer()) source_template_id = Column(UUID(as_uuid=True), nullable=True) + def __repr__(self): + return f"Section({self.name_in_apply_json['en']}, Forms: {self.forms})" + + def as_dict(self): + return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns} + @dataclass class Form(BaseModel): section_id = Column( - "section_id", UUID(as_uuid=True), ForeignKey("section.section_id"), nullable=True, # will be null where this is a template and not linked to a section ) form_id = Column( - "form_id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, ) - name_in_apply = Column("name_in_apply_json", JSON(none_as_null=True), nullable=False, unique=False) - template_name = Column("Template Name", String(), nullable=True) - is_template = Column("is_template", Boolean, default=False, nullable=False) - audit_info = Column("audit_info", JSON(none_as_null=True)) + name_in_apply_json = Column(JSON(none_as_null=True), nullable=False, unique=False) + template_name = Column(String(), nullable=True) + is_template = Column(Boolean, default=False, nullable=False) + audit_info = Column(JSON(none_as_null=True)) section_index = Column(Integer()) pages: Mapped[List["Page"]] = relationship( "Page", order_by="Page.form_index", collection_class=ordering_list("form_index") @@ -87,37 +91,45 @@ class Form(BaseModel): source_template_id = Column(UUID(as_uuid=True), nullable=True) def __repr__(self): - return f"Form({self.runner_publish_name} - {self.name_in_apply['en']}, Pages: {self.pages})" + return f"Form({self.runner_publish_name} - {self.name_in_apply_json['en']}, Pages: {self.pages})" + + def as_dict(self): + return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns} @dataclass class Page(BaseModel): form_id = Column( - "form_id", UUID(as_uuid=True), ForeignKey("form.form_id"), nullable=True, # will be null where this is a template and not linked to a form ) page_id = Column( - "page_id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, ) - name_in_apply = Column("name_in_apply_json", JSON(none_as_null=True), nullable=False, unique=False) - template_name = Column("Template Name", String(), nullable=True) - is_template = Column("is_template", Boolean, default=False, nullable=False) - audit_info = Column("audit_info", JSON(none_as_null=True)) + name_in_apply_json = Column(JSON(none_as_null=True), nullable=False, unique=False) + template_name = Column(String(), nullable=True) + is_template = Column(Boolean, default=False, nullable=False) + audit_info = Column(JSON(none_as_null=True)) form_index = Column(Integer()) - display_path = Column("display_path", String()) + display_path = Column(String()) components: Mapped[List["Component"]] = relationship( "Component", order_by="Component.page_index", collection_class=ordering_list("page_index") ) source_template_id = Column(UUID(as_uuid=True), nullable=True) def __repr__(self): - return f"Page(/{self.display_path} - {self.name_in_apply['en']}, Components: {self.components})" + return f"Page(/{self.display_path} - {self.name_in_apply_json['en']}, Components: {self.components})" + + def as_dict(self): + return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns} + + +# Ensure we can only have one template with a particular display_path value +Index("ix_template_page_name", Page.display_path, unique=True, postgresql_where="Page.is_template = true") class Lizt(BaseModel): @@ -129,19 +141,21 @@ class Lizt(BaseModel): name = Column(String()) type = Column(String()) items = Column(JSON()) + is_template = Column(Boolean, default=False, nullable=False) + + def as_dict(self): + return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns} @dataclass class Component(BaseModel): component_id = Column( - "component_id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, ) page_id = Column( - "page_id", UUID(as_uuid=True), ForeignKey("page.page_id"), nullable=True, # will be null where this is a template and not linked to a page @@ -156,9 +170,9 @@ class Component(BaseModel): hint_text = Column(String(), nullable=True) options = Column(JSON(none_as_null=False)) type = Column(ENUM(ComponentType)) - template_name = Column("Template Name", String(), nullable=True) - is_template = Column("is_template", Boolean, default=False, nullable=False) - audit_info = Column("audit_info", JSON(none_as_null=True)) + template_name = Column(String(), nullable=True) + is_template = Column(Boolean, default=False, nullable=False) + audit_info = Column(JSON(none_as_null=True)) page_index = Column(Integer()) theme_index = Column(Integer()) conditions = Column(JSON(none_as_null=True)) @@ -177,6 +191,9 @@ class Component(BaseModel): def __repr__(self): return f"Component({self.title}, {self.type.value})" + def as_dict(self): + return {col.name: self.__getattribute__(col.name) for col in inspect(self).mapper.columns} + @property def assessment_display_type(self): # TODO extend this to account for what's in self.options eg. if prefix==£, return 'currency' diff --git a/app/db/models/assessment_config.py b/app/db/models/assessment_config.py index c0c0dfd..0d906aa 100644 --- a/app/db/models/assessment_config.py +++ b/app/db/models/assessment_config.py @@ -4,11 +4,11 @@ from flask_sqlalchemy.model import DefaultMeta from sqlalchemy import Column -from sqlalchemy import Float from sqlalchemy import ForeignKey from sqlalchemy import Integer from sqlalchemy import String from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.dialects.postgresql import REAL from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped from sqlalchemy.orm import relationship @@ -33,7 +33,7 @@ class Criteria(BaseModel): nullable=True, ) name = Column(String()) - weighting = Column(Float(precision=2)) + weighting = Column(REAL(precision=2)) template_name = Column("Template Name", String(), nullable=True) is_template = Column("is_template", Boolean, default=False, nullable=False) audit_info = Column("audit_info", JSON(none_as_null=True)) diff --git a/app/db/queries/application.py b/app/db/queries/application.py index cf3fd7e..0938d57 100644 --- a/app/db/queries/application.py +++ b/app/db/queries/application.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + from app.db import db from app.db.models import Component from app.db.models import Form @@ -35,3 +37,71 @@ def get_component_by_id(component_id: str) -> Component: def get_list_by_id(list_id: str) -> Lizt: lizt = db.session.query(Lizt).where(Lizt.list_id == list_id).one_or_none() return lizt + + +def _initiate_cloned_component(to_clone: Component, new_page_id=None, new_theme_id=None): + clone = Component(**to_clone.as_dict()) + + clone.component_id = uuid4() + clone.page_id = new_page_id + clone.theme_id = new_theme_id + clone.is_template = False + clone.source_template_id = to_clone.component_id + clone.template_name = None + return clone + + +def _initiate_cloned_page(to_clone: Page, new_form_id=None): + clone = Page(**to_clone.as_dict()) + clone.page_id = uuid4() + clone.form_id = new_form_id + clone.is_template = False + clone.source_template_id = to_clone.page_id + clone.template_name = None + clone.components = [] + return clone + + +def clone_single_page(page_id: str, new_form_id=None) -> Page: + page_to_clone: Page = db.session.query(Page).where(Page.page_id == page_id).one_or_none() + clone = _initiate_cloned_page(page_to_clone, new_form_id) + + cloned_components = [] + for component_to_clone in page_to_clone.components: + + cloned_component = _initiate_cloned_component( + component_to_clone, new_page_id=clone.page_id, new_theme_id=None + ) # TODO how should themes work when cloning? + cloned_components.append(cloned_component) + # clone.components = cloned_components + db.session.add_all([clone, *cloned_components]) + db.session.commit() + + return clone + + +def clone_single_component(component_id: str, new_page_id=None, new_theme_id=None) -> Component: + component_to_clone: Component = ( + db.session.query(Component).where(Component.component_id == component_id).one_or_none() + ) + clone = _initiate_cloned_component(component_to_clone, new_page_id, new_theme_id) + + db.session.add(clone) + db.session.commit() + + return clone + + +# TODO do we need this? +def clone_multiple_components(component_ids: list[str], new_page_id=None, new_theme_id=None) -> list[Component]: + components_to_clone: list[Component] = ( + db.session.query(Component).filter(Component.component_id.in_(component_ids)).all() + ) + clones = [ + _initiate_cloned_component(to_clone=to_clone, new_page_id=new_page_id, new_theme_id=new_theme_id) + for to_clone in components_to_clone + ] + db.session.add_all(clones) + db.session.commit() + + return clones diff --git a/app/db/queries/fund.py b/app/db/queries/fund.py index 5b3c482..1810daf 100644 --- a/app/db/queries/fund.py +++ b/app/db/queries/fund.py @@ -1,3 +1,5 @@ +from sqlalchemy import select + from app.db import db from app.db.models.fund import Fund @@ -9,10 +11,9 @@ def add_fund(fund: Fund) -> Fund: def get_all_funds() -> list: - query = db.session.query(Fund).order_by(Fund.short_name) - return query.all() + stmt = select(Fund).order_by(Fund.short_name) + return db.session.scalars(stmt).all() def get_fund_by_id(id: str) -> Fund: - query = db.session.query(Fund).where(Fund.fund_id == id) - return query.one_or_none() + return db.session.get(Fund, id) diff --git a/app/db/queries/round.py b/app/db/queries/round.py index 1f43207..4c84e8d 100644 --- a/app/db/queries/round.py +++ b/app/db/queries/round.py @@ -9,5 +9,4 @@ def add_round(round: Round) -> Round: def get_round_by_id(id: str) -> Round: - query = db.session.query(Round).where(Round.round_id == id) - return query.one_or_none() + return db.session.get(Round, id) diff --git a/app/question_reuse/generate_form.py b/app/question_reuse/generate_form.py index 7cc9013..011e39d 100644 --- a/app/question_reuse/generate_form.py +++ b/app/question_reuse/generate_form.py @@ -104,7 +104,7 @@ def build_page(page: Page = None, page_display_path: str = None) -> dict: built_page.update( { "path": f"/{page.display_path}", - "title": page.name_in_apply["en"], + "title": page.name_in_apply_json["en"], } ) # Having a 'null' controller element breaks the form-json, needs to not be there if blank @@ -209,7 +209,7 @@ def human_to_kebab_case(word: str) -> str | None: def build_form_json(form: Form) -> dict: results = copy.deepcopy(BASIC_FORM_STRUCTURE) - results["name"] = form.name_in_apply["en"] + results["name"] = form.name_in_apply_json["en"] for page in form.pages: results["pages"].append(build_page(page=page)) @@ -217,8 +217,8 @@ def build_form_json(form: Form) -> dict: start_page = copy.deepcopy(BASIC_PAGE_STRUCTURE) start_page.update( { - "title": form.name_in_apply["en"], - "path": f"/intro-{human_to_kebab_case(form.name_in_apply['en'])}", + "title": form.name_in_apply_json["en"], + "path": f"/intro-{human_to_kebab_case(form.name_in_apply_json['en'])}", "controller": "./pages/start.js", "next": [{"path": f"/{form.pages[0].display_path}"}], } diff --git a/config/envs/unit_test.py b/config/envs/unit_test.py index 526846e..d8ed1fb 100644 --- a/config/envs/unit_test.py +++ b/config/envs/unit_test.py @@ -13,5 +13,6 @@ class UnitTestConfig(Config): FSD_LOG_LEVEL = logging.DEBUG SQLALCHEMY_DATABASE_URI = getenv( - "DATABASE_URL", "postgresql://postgres:postgres@127.0.0.1:5432/fab_unit_test" # pragma: allowlist secret + "DATABASE_URL_UNIT_TEST", + "postgresql://postgres:postgres@127.0.0.1:5432/fab_unit_test", # pragma: allowlist secret ) diff --git a/docker-compose.yml b/docker-compose.yml index 8b0a44f..e4c5051 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: command: sleep infinity environment: - DATABASE_URL=postgresql://postgres:password@fab-db:5432/fab + - DATABASE_URL_UNIT_TEST=postgresql://postgres:password@fab-db:5432/fab_unit_test fab-db: diff --git a/tasks/db_tasks.py b/tasks/db_tasks.py index d7d2c24..122aad9 100644 --- a/tasks/db_tasks.py +++ b/tasks/db_tasks.py @@ -8,6 +8,7 @@ from app.app import app # noqa:E402 +from .test_data import init_salmon_fishing_fund # noqa:E402 from .test_data import insert_test_data # noqa:E402 @@ -62,7 +63,7 @@ def create_test_data(c): ) ) db.session.commit() - insert_test_data(db=db) + insert_test_data(db=db, test_data=init_salmon_fishing_fund()) @task diff --git a/tasks/test_data.py b/tasks/test_data.py index a066d40..8dc31f4 100644 --- a/tasks/test_data.py +++ b/tasks/test_data.py @@ -14,8 +14,27 @@ from app.db.models import Subcriteria from app.db.models import Theme +BASIC_FUND_INFO = { + "name_json": {"en": "Unit Test Fund"}, + "title_json": {"en": "funding to improve testing"}, + "description_json": {"en": "A £10m fund to improve testing across the devolved nations."}, + "welsh_available": False, +} +BASIC_ROUND_INFO = { + "audit_info": {"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, + "title_json": {"en": "round the first"}, + "opens": datetime.now(), + "deadline": datetime.now(), + "assessment_start": datetime.now(), + "reminder_date": datetime.now(), + "assessment_deadline": datetime.now(), + "prospectus_link": "http://www.google.com", + "privacy_notice_link": "http://www.google.com", +} + + +def init_salmon_fishing_fund(): -def init_data() -> dict: f: Fund = Fund( fund_id=uuid4(), name_json={"en": "Salmon Fishing Fund"}, @@ -57,19 +76,19 @@ def init_data() -> dict: ) s1: Section = Section( - section_id=uuid4(), index=1, round_id=r.round_id, name_in_apply={"en": "Organisation Information"} + section_id=uuid4(), index=1, round_id=r.round_id, name_in_apply_json={"en": "Organisation Information"} ) f1: Form = Form( form_id=uuid4(), section_id=s1.section_id, - name_in_apply={"en": "About your organisation"}, + name_in_apply_json={"en": "About your organisation"}, section_index=1, runner_publish_name="about-your-org", ) f2: Form = Form( form_id=uuid4(), section_id=s1.section_id, - name_in_apply={"en": "Contact Details"}, + name_in_apply_json={"en": "Contact Details"}, section_index=2, runner_publish_name="contact-details", ) @@ -77,54 +96,38 @@ def init_data() -> dict: page_id=uuid4(), form_id=f1.form_id, display_path="organisation-name", - name_in_apply={"en": "Organisation Name"}, + name_in_apply_json={"en": "Organisation Name"}, form_index=1, ) p2: Page = Page( page_id=uuid4(), display_path="organisation-address", form_id=f1.form_id, - name_in_apply={"en": "Organisation Address"}, + name_in_apply_json={"en": "Organisation Address"}, form_index=3, ) p3: Page = Page( page_id=uuid4(), form_id=f2.form_id, display_path="lead-contact-details", - name_in_apply={"en": "Lead Contact Details"}, + name_in_apply_json={"en": "Lead Contact Details"}, form_index=1, ) p5: Page = Page( page_id=uuid4(), display_path="organisation-classification", form_id=f1.form_id, - name_in_apply={"en": "Organisation Classification"}, + name_in_apply_json={"en": "Organisation Classification"}, form_index=4, ) - p4: Page = Page( + p_org_alt_names: Page = Page( page_id=uuid4(), form_id=None, display_path="organisation-alternative-names", - name_in_apply={"en": "Alternative names of your organisation"}, + name_in_apply_json={"en": "Alternative names of your organisation"}, form_index=2, is_template=True, ) - template_page: Page = Page( - page_id=uuid4(), - form_id=None, - display_path="testing_templates_path", - is_template=True, - name_in_apply={"en": "Template Path"}, - form_index=0, - ) - non_template_page: Page = Page( - page_id=uuid4(), - form_id=None, - display_path="testing_templates_path", - is_template=False, - name_in_apply={"en": "Not Template Path"}, - form_index=0, - ) cri1: Criteria = Criteria(criteria_id=uuid4(), index=1, round_id=r.round_id, name="Unscored", weighting=0.0) sc1: Subcriteria = Subcriteria( subcriteria_id=uuid4(), criteria_index=1, criteria_id=cri1.criteria_id, name="Organisation Information" @@ -217,7 +220,7 @@ def init_data() -> dict: ) c7: Component = Component( component_id=uuid4(), - page_id=p4.page_id, + page_id=p_org_alt_names.page_id, title="Alternative Name 1", type=ComponentType.TEXT_FIELD, page_index=1, @@ -231,6 +234,7 @@ def init_data() -> dict: name="classifications_list", type="string", items=[{"text": "Charity", "value": "charity"}, {"text": "Public Limited Company", "value": "plc"}], + is_template=True, ) c8: Component = Component( component_id=uuid4(), @@ -250,14 +254,125 @@ def init_data() -> dict: "rounds": [r, r2], "sections": [s1], "forms": [f1, f2], - "pages": [p1, p2, p3, template_page, non_template_page, p4, p5], - "components": [c1, c2, c3, c4, c5, c6, c7, c8], + "pages": [p1, p2, p3, p5, p_org_alt_names], + "components": [c1, c2, c4, c5, c6, c8, c3, c7], "criteria": [cri1], "subcriteria": [sc1], "themes": [t1, t2], } +def init_unit_test_data() -> dict: + f: Fund = Fund( + fund_id=uuid4(), + name_json={"en": "Unit Test Fund 1"}, + title_json={"en": "funding to improve testing"}, + description_json={"en": "A £10m fund to improve testing across the devolved nations."}, + welsh_available=False, + short_name=f"UTF{randint(0,999)}", + ) + + r: Round = Round( + round_id=uuid4(), + fund_id=f.fund_id, + audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, + title_json={"en": "round the first"}, + short_name=f"UTR{randint(0,999)}", + opens=datetime.now(), + deadline=datetime.now(), + assessment_start=datetime.now(), + reminder_date=datetime.now(), + assessment_deadline=datetime.now(), + prospectus_link="http://www.google.com", + privacy_notice_link="http://www.google.com", + ) + # r2: Round = Round( + # round_id=uuid4(), + # fund_id=f.fund_id, + # audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, + # title_json={"en": "round the second"}, + # short_name=f"UTR{randint(0,999)}", + # opens=datetime.now(), + # deadline=datetime.now(), + # assessment_start=datetime.now(), + # reminder_date=datetime.now(), + # assessment_deadline=datetime.now(), + # prospectus_link="http://www.google.com", + # privacy_notice_link="http://www.google.com", + # ) + + s1: Section = Section( + section_id=uuid4(), index=1, round_id=r.round_id, name_in_apply_json={"en": "Organisation Information"} + ) + f1: Form = Form( + form_id=uuid4(), + section_id=s1.section_id, + name_in_apply_json={"en": "About your organisation"}, + section_index=1, + runner_publish_name="about-your-org", + ) + p1: Page = Page( + page_id=uuid4(), + form_id=f1.form_id, + display_path="organisation-name", + name_in_apply_json={"en": "Organisation Name"}, + form_index=1, + ) + + cri1: Criteria = Criteria(criteria_id=uuid4(), index=1, round_id=r.round_id, name="Unscored", weighting=0.0) + sc1: Subcriteria = Subcriteria( + subcriteria_id=uuid4(), criteria_index=1, criteria_id=cri1.criteria_id, name="Organisation Information" + ) + t1: Theme = Theme( + theme_id=uuid4(), subcriteria_id=sc1.subcriteria_id, name="General Information", subcriteria_index=1 + ) + + c1: Component = Component( + component_id=uuid4(), + page_id=p1.page_id, + title="What is your organisation's name?", + hint_text="This must match the regsitered legal organisation name", + type=ComponentType.TEXT_FIELD, + page_index=1, + theme_id=t1.theme_id, + theme_index=1, + options={"hideTitle": False, "classes": ""}, + runner_component_name="organisation_name", + ) + + l1: Lizt = Lizt( + list_id=uuid4(), + name="classifications_list", + type="string", + items=[{"text": "Charity", "value": "charity"}, {"text": "Public Limited Company", "value": "plc"}], + is_template=True, + ) + c_with_list: Component = Component( + component_id=uuid4(), + page_id=p1.page_id, + title="How is your organisation classified?", + type=ComponentType.RADIOS_FIELD, + page_index=2, + theme_id=t1.theme_id, + theme_index=6, + options={"hideTitle": False, "classes": ""}, + runner_component_name="organisation_classification", + list_id=l1.list_id, + ) + return { + "lists": [l1], + "funds": [f], + "rounds": [r], + "sections": [s1], + "forms": [f1], + "pages": [p1], + "components": [c1, c_with_list], + "criteria": [cri1], + "subcriteria": [sc1], + "themes": [t1], + } + + def insert_test_data(db, test_data={}): db.session.bulk_save_objects(test_data.get("funds", [])) db.session.commit() diff --git a/tests/conftest.py b/tests/conftest.py index 320eecf..2c5b73f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ from flask_migrate import upgrade from app.app import create_app -from tasks.test_data import init_data +from tasks.test_data import init_unit_test_data from tasks.test_data import insert_test_data pytest_plugins = ["fsd_test_utils.fixtures.db_fixtures"] @@ -13,7 +13,7 @@ def seed_dynamic_data(request, app, clear_test_data, _db, enable_preserve_test_d marker = request.node.get_closest_marker("seed_config") if marker is None: - fab_seed_data = init_data() + fab_seed_data = init_unit_test_data() else: fab_seed_data = marker.args[0] insert_test_data(db=_db, test_data=fab_seed_data) diff --git a/tests/test_build_forms.py b/tests/test_build_forms.py index 1440fa4..904ec0f 100644 --- a/tests/test_build_forms.py +++ b/tests/test_build_forms.py @@ -592,7 +592,7 @@ # results = build_form_json(form=input_form) # assert results # assert len(results["pages"]) == len(exp_results["pages"]) -# assert results["name"] == input_form.name_in_apply["en"] +# assert results["name"] == input_form.name_in_apply_json["en"] # for exp_page in exp_results["pages"]: # result_page = next(res_page for res_page in results["pages"] if res_page["path"] == exp_page["path"]) # assert result_page["title"] == exp_page["title"] diff --git a/tests/test_clone.py b/tests/test_clone.py new file mode 100644 index 0000000..1719243 --- /dev/null +++ b/tests/test_clone.py @@ -0,0 +1,326 @@ +from uuid import uuid4 + +import pytest + +from app.db.models import Component +from app.db.models import ComponentType +from app.db.models import Page +from app.db.queries.application import _initiate_cloned_component +from app.db.queries.application import _initiate_cloned_page +from app.db.queries.application import clone_multiple_components +from app.db.queries.application import clone_single_component +from app.db.queries.application import clone_single_page + + +@pytest.fixture +def mock_new_uuid(mocker): + new_id = uuid4() + mocker.patch("app.db.queries.application.uuid4", return_value=new_id) + yield new_id + + +# ===================================================================================================================== +# These functions mock the _initiate_cloned_XXX functions and don't use the db +# ===================================================================================================================== + + +def test_initiate_cloned_page(mock_new_uuid): + clone: Page = Page( + page_id="old-id", + name_in_apply_json={"en": "test page 1"}, + form_id="old-form-id", + is_template=True, + template_name="Template Page", + display_path="template-page", + ) + result: Page = _initiate_cloned_page(to_clone=clone, new_form_id="new-form") + assert result + assert result.page_id == mock_new_uuid + + # Check other bits are the same + assert result.name_in_apply_json == clone.name_in_apply_json + assert result.display_path == clone.display_path + + # check template settings + assert result.is_template is False + assert result.source_template_id == "old-id" + assert result.template_name is None + + assert result.form_id == "new-form" + + +def test_initiate_cloned_component(mock_new_uuid): + clone: Component = Component( + component_id="old-id", + page_id="pre-clone", + title="Template qustion 1?", + type=ComponentType.TEXT_FIELD, + template_name="Template Component", + is_template=True, + page_index=1, + theme_id="pre-clone", + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name", + conditions={"a": "b"}, + ) + result = _initiate_cloned_component(clone, "page-123", "theme-234") + + assert result + + # Check new ID + assert result.component_id == mock_new_uuid + + # Check other bits are the same + assert result.title == clone.title + assert result.type == clone.type + assert result.options == clone.options + assert result.conditions == clone.conditions + + # check template settings + assert result.is_template is False + assert result.source_template_id == "old-id" + assert result.template_name is None + + assert result.page_id == "page-123" + assert result.theme_id == "theme-234" + + +# ===================================================================================================================== +# These functions mock the clone_XXX functions and DO use the db +# ===================================================================================================================== + + +def test_clone_single_component(flask_test_client, _db): + template_component: Component = Component( + component_id=uuid4(), + page_id=None, + title="Template qustion 1?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name", + ) + + old_id = template_component.component_id + + _db.session.bulk_save_objects([template_component]) + _db.session.commit() + + assert _db.session.get(Component, old_id) + + result = clone_single_component(template_component.component_id) + assert result + new_id = result.component_id + + # check can retrieve new component + assert _db.session.get(Component, new_id) + + # Check new ID + assert result.component_id != template_component.component_id + + # Check other bits are the same + assert result.title == template_component.title + assert result.type == template_component.type + assert result.options == template_component.options + assert result.conditions is None + + # check template settings + assert result.is_template is False + assert result.source_template_id == template_component.component_id + + # check can retrieve old component + assert _db.session.get(Component, old_id) + + +page_id = uuid4() + + +@pytest.mark.seed_config( + { + "components": [ + Component( + component_id=uuid4(), + page_id=None, + title="Template qustion 1?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_1", + is_template=True, + ), + Component( + component_id=uuid4(), + page_id=None, + title="Template qustion 2?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_2", + is_template=True, + ), + Component( + component_id=uuid4(), + page_id=None, + title="Template qustion 3?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_3", + is_template=True, + ), + ], + } +) +def test_clone_multiple_components(seed_dynamic_data, _db): + existing_components = seed_dynamic_data["components"] + + results = clone_multiple_components( + component_ids=[c.component_id for c in existing_components], + new_page_id=None, + new_theme_id=None, + ) + assert results + assert len(results) == 3 + + # Check new component exists in db + from_db = _db.session.query(Component).filter(Component.component_id.in_([c.component_id for c in results])).all() + assert from_db + assert len(from_db) == 3 + + # Check the old ones exist + from_db = ( + _db.session.query(Component) + .filter(Component.component_id.in_([c.component_id for c in existing_components])) + .all() + ) + assert from_db + assert len(from_db) == 3 + + +@pytest.mark.seed_config( + { + "pages": [ + Page( + page_id=uuid4(), + form_id=None, + display_path="testing-clones-no-components", + is_template=True, + name_in_apply_json={"en": "Clone testing"}, + form_index=0, + ) + ] + } +) +def test_clone_page_no_components(seed_dynamic_data, _db): + + old_id = seed_dynamic_data["pages"][0].page_id + + # check initial page exists + initial_page_from_db = _db.session.query(Page).where(Page.page_id == old_id).one_or_none() + assert initial_page_from_db + + result = clone_single_page(page_id=old_id, new_form_id=None) + assert result + new_id = result.page_id + + # check new page exists + new_page_from_db = _db.session.query(Page).where(Page.page_id == new_id).one_or_none() + assert new_page_from_db + + # check old page still exists + old_page_from_db = _db.session.query(Page).where(Page.page_id == old_id).one_or_none() + assert old_page_from_db + + +page_id = uuid4() + + +@pytest.mark.seed_config( + { + "pages": [ + Page( + page_id=page_id, + form_id=None, + display_path="testing-clones-with-components", + is_template=True, + name_in_apply_json={"en": "Clone testing"}, + form_index=0, + ) + ], + "components": [ + Component( + component_id=uuid4(), + page_id=page_id, + title="Template qustion 1?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_1", + is_template=True, + ), + Component( + component_id=uuid4(), + page_id=page_id, + title="Template qustion 2?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_2", + is_template=True, + ), + Component( + component_id=uuid4(), + page_id=page_id, + title="Template qustion 3?", + type=ComponentType.YES_NO_FIELD, + page_index=1, + theme_id=None, + theme_index=2, + options={"hideTitle": False, "classes": "test-class"}, + runner_component_name="template_question_name_3", + is_template=True, + ), + ], + } +) +def test_clone_page_with_components(seed_dynamic_data, _db): + + old_page_id = seed_dynamic_data["pages"][0].page_id + old_component_ids = [str(c.component_id) for c in seed_dynamic_data["components"]] + + # check initial page exists + initial_page_from_db = _db.session.query(Page).where(Page.page_id == old_page_id).one_or_none() + assert initial_page_from_db + + result = clone_single_page(page_id=old_page_id, new_form_id=None) + assert result + new_id = result.page_id + + # check new page exists + new_page_from_db = _db.session.query(Page).where(Page.page_id == new_id).one_or_none() + assert new_page_from_db + # check components have also been cloned + assert len(new_page_from_db.components) == 3 + for component in new_page_from_db.components: + assert str(component.component_id) not in old_component_ids + + # check old page still exists + old_page_from_db = _db.session.query(Page).where(Page.page_id == old_page_id).one_or_none() + assert old_page_from_db + # check old page still has references to original components + assert len(old_page_from_db.components) == 3 + for component in old_page_from_db.components: + assert str(component.component_id) in old_component_ids diff --git a/tests/test_db.py b/tests/test_db.py index 71dc659..d8a60e3 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -2,7 +2,7 @@ from random import randint from uuid import uuid4 -from sqlalchemy import text +import pytest from app.db.models import Form from app.db.models import Fund @@ -30,11 +30,24 @@ def test_add_fund(flask_test_client, _db): assert result.fund_id -def test_add_round(flask_test_client, _db): - fund = _db.session.execute(text("select * from fund limit 1;")).one() +@pytest.mark.seed_config( + { + "funds": [ + Fund( + fund_id=uuid4(), + name_json={"en": "Test Fund To Create Rounds"}, + title_json={"en": "funding to improve stuff"}, + description_json={"en": "A £10m fund to improve stuff across the devolved nations."}, + welsh_available=False, + short_name="TFCR1", + ) + ] + } +) +def test_add_round(seed_dynamic_data): result = add_round( Round( - fund_id=fund.fund_id, + fund_id=seed_dynamic_data["funds"][0].fund_id, audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, title_json={"en": "test title"}, short_name=f"Z{randint(0,99999)}", @@ -54,12 +67,27 @@ def test_add_round(flask_test_client, _db): def test_get_all_funds(flask_test_client, _db): results = get_all_funds() assert results - - -def test_get_fund_by_id(flask_test_client, _db): - any_fund = _db.session.execute(text("select * from fund limit 1;")).one() - result: Fund = get_fund_by_id(any_fund.fund_id) - assert result.name_json == any_fund.name_json + assert results[0].fund_id + + +@pytest.mark.seed_config( + { + "funds": [ + Fund( + fund_id=uuid4(), + name_json={"en": "Test Fund 1"}, + title_json={"en": "funding to improve stuff"}, + description_json={"en": "A £10m fund to improve stuff across the devolved nations."}, + welsh_available=False, + short_name="TF1", + ) + ] + } +) +def test_get_fund_by_id(seed_dynamic_data): + result: Fund = get_fund_by_id(seed_dynamic_data["funds"][0].fund_id) + assert result + assert result.name_json["en"] == "Test Fund 1" def test_get_fund_by_id_none(flask_test_client, _db): @@ -72,54 +100,94 @@ def test_get_round_by_id_none(flask_test_client, _db): assert result is None -def test_get_round_by_id(flask_test_client, _db): - any_round = _db.session.execute(text("select * from round limit 1;")).one() - result: Round = get_round_by_id(any_round.round_id) - assert result.title_json == any_round.title_json - +fund_id = uuid4() + + +@pytest.mark.seed_config( + { + "funds": [ + Fund( + fund_id=fund_id, + name_json={"en": "Test Fund 1"}, + title_json={"en": "funding to improve stuff"}, + description_json={"en": "A £10m fund to improve stuff across the devolved nations."}, + welsh_available=False, + short_name="TFR1", + ) + ], + "rounds": [ + Round( + round_id=uuid4(), + fund_id=fund_id, + audit_info={"user": "dummy_user", "timestamp": datetime.now().isoformat(), "action": "create"}, + title_json={"en": "round the first"}, + short_name="R1", + opens=datetime.now(), + deadline=datetime.now(), + assessment_start=datetime.now(), + reminder_date=datetime.now(), + assessment_deadline=datetime.now(), + prospectus_link="http://www.google.com", + privacy_notice_link="http://www.google.com", + ) + ], + } +) +def test_get_round_by_id(seed_dynamic_data): + + result: Round = get_round_by_id(seed_dynamic_data["rounds"][0].round_id) + assert result.title_json["en"] == "round the first" + + +@pytest.mark.seed_config( + { + "pages": [ + Page( + page_id=uuid4(), + form_id=None, + display_path="testing_templates_path", + is_template=True, + name_in_apply_json={"en": "Template Path"}, + form_index=0, + ), + Page( + page_id=uuid4(), + form_id=None, + display_path="testing_templates_path", + is_template=False, + name_in_apply_json={"en": "Not Template Path"}, + form_index=0, + ), + ] + } +) +def test_get_template_page_by_display_path(seed_dynamic_data): -def test_get_template_page_by_display_path(flask_test_client, _db): - _db.session.execute( - text("TRUNCATE TABLE fund, round, section,form, page, component, theme, subcriteria, criteria CASCADE;") - ) - _db.session.commit() - - template_page: Page = Page( - page_id=uuid4(), - form_id=None, - display_path="testing_templates_path", - is_template=True, - name_in_apply={"en": "Template Path"}, - form_index=0, - ) - non_template_page: Page = Page( - page_id=uuid4(), - form_id=None, - display_path="testing_templates_path", - is_template=False, - name_in_apply={"en": "Not Template Path"}, - form_index=0, - ) - - _db.session.bulk_save_objects([template_page, non_template_page]) - _db.session.commit() result = get_template_page_by_display_path("testing_templates_path") assert result - assert result.page_id == template_page.page_id + assert result.page_id == seed_dynamic_data["pages"][0].page_id -def test_form_sorting(flask_test_client, _db): - # Create a section with one form, at index 1 - section: Section = Section(section_id=uuid4(), name_in_apply={"en": "hello section"}) - form1: Form = Form(form_id=uuid4(), section_id=section.section_id, section_index=1, name_in_apply={"en": "Form 1"}) - _db.session.bulk_save_objects([section, form1]) - _db.session.commit() +section_id = uuid4() + +# Create a section with one form, at index 1 +@pytest.mark.seed_config( + { + "sections": [Section(section_id=section_id, name_in_apply_json={"en": "hello section"})], + "forms": [Form(form_id=uuid4(), section_id=section_id, section_index=1, name_in_apply_json={"en": "Form 1"})], + } +) +def test_form_sorting(seed_dynamic_data, _db): + section = seed_dynamic_data["sections"][0] + form1 = seed_dynamic_data["forms"][0] result_section = _db.session.query(Section).where(Section.section_id == section.section_id).one_or_none() assert len(result_section.forms) == 1 # add a form at index 2, confirm ordering - form2: Form = Form(form_id=uuid4(), section_id=section.section_id, section_index=2, name_in_apply={"en": "Form 2"}) + form2: Form = Form( + form_id=uuid4(), section_id=section.section_id, section_index=2, name_in_apply_json={"en": "Form 2"} + ) _db.session.add(form2) _db.session.commit() @@ -129,7 +197,9 @@ def test_form_sorting(flask_test_client, _db): assert result_section.forms[1].form_id == form2.form_id # add a form at index 0, confirm ordering - form0: Form = Form(form_id=uuid4(), section_id=section.section_id, section_index=0, name_in_apply={"en": "Form 0"}) + form0: Form = Form( + form_id=uuid4(), section_id=section.section_id, section_index=0, name_in_apply_json={"en": "Form 0"} + ) _db.session.add(form0) _db.session.commit() @@ -140,7 +210,7 @@ def test_form_sorting(flask_test_client, _db): assert result_section.forms[2].form_id == form2.form_id # insert a form between 1 and 2, check ordering - formX: Form = Form(form_id=uuid4(), section_id=section.section_id, name_in_apply={"en": "Form X"}) + formX: Form = Form(form_id=uuid4(), section_id=section.section_id, name_in_apply_json={"en": "Form X"}) result_section.forms.insert(2, formX) _db.session.bulk_save_objects([result_section]) _db.session.commit() diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py index 1e18cad..c618607 100644 --- a/tests/test_generate_form.py +++ b/tests/test_generate_form.py @@ -211,7 +211,7 @@ def test_build_lists(mocker, pages, exp_result): page_id=uuid4(), form_id=uuid4(), display_path="organisation-single-name", - name_in_apply={"en": "Organisation Name"}, + name_in_apply_json={"en": "Organisation Name"}, form_index=1, components=[mock_c_1], ), @@ -356,7 +356,7 @@ def test_build_conditions(input_component, exp_results): page_id=uuid4(), form_id=uuid4(), display_path="organisation-single-name", - name_in_apply={"en": "Organisation Name"}, + name_in_apply_json={"en": "Organisation Name"}, form_index=1, ) ], @@ -394,14 +394,14 @@ def test_build_conditions(input_component, exp_results): page_id=uuid4(), form_id=uuid4(), display_path="organisation-single-name", - name_in_apply={"en": "Organisation Name"}, + name_in_apply_json={"en": "Organisation Name"}, form_index=1, ), Page( page_id=uuid4(), form_id=uuid4(), display_path="organisation-charitable-objects", - name_in_apply={"en": "What are your organisation's charitable objects?"}, + name_in_apply_json={"en": "What are your organisation's charitable objects?"}, form_index=1, ), ], @@ -469,7 +469,7 @@ def test_build_navigation_no_conditions(mocker, input_partial_json, input_pages, page_id=uuid4(), form_id=uuid4(), display_path="organisation-name", - name_in_apply={"en": "Organisation Name"}, + name_in_apply_json={"en": "Organisation Name"}, form_index=1, components=[ Component( @@ -498,7 +498,7 @@ def test_build_navigation_no_conditions(mocker, input_partial_json, input_pages, # page_id=uuid4(), # form_id=uuid4(), # display_path="organisation-alternative-names", - # name_in_apply={"en": "Organisation Alternative Names"}, + # name_in_apply_json={"en": "Organisation Alternative Names"}, # form_index=2, # ), ], @@ -660,7 +660,7 @@ def test_build_form(input_form, exp_results): results = build_form_json(form=input_form) assert results assert len(results["pages"]) == len(exp_results["pages"]) - assert results["name"] == input_form.name_in_apply["en"] + assert results["name"] == input_form.name_in_apply_json["en"] for exp_page in exp_results["pages"]: result_page = next((res_page for res_page in results["pages"] if res_page["path"] == exp_page["path"]), None) assert result_page, f"{exp_page['path']}" diff --git a/tests/test_integration.py b/tests/test_integration.py index 1b7460d..1fb87fa 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,20 +7,158 @@ from app.db.models import Form from app.db.models import Fund from app.db.models import Lizt +from app.db.models import Page +from app.db.models import Round +from app.db.models import Section from app.db.queries.application import get_component_by_id -from app.db.queries.fund import get_all_funds +from app.db.queries.fund import get_fund_by_id from app.question_reuse.generate_assessment_config import build_assessment_config from app.question_reuse.generate_form import build_form_json +from tasks.test_data import BASIC_FUND_INFO +from tasks.test_data import BASIC_ROUND_INFO -def test_build_form_json(seed_dynamic_data): +def test_build_form_json_no_conditions(seed_dynamic_data): - f: Fund = get_all_funds()[0] + f: Fund = get_fund_by_id(seed_dynamic_data["funds"][0].fund_id) form: Form = f.rounds[0].sections[0].forms[0] result = build_form_json(form=form) assert result - assert len(result["pages"]) == 6 + assert len(result["pages"]) == 3 + exp_start_path = "/intro-about-your-organisation" + exp_second_path = "/organisation-name" + assert result["startPage"] == exp_start_path + intro_page = next((p for p in result["pages"] if p["path"] == exp_start_path), None) + assert intro_page + assert intro_page["next"][0]["path"] == exp_second_path + + org_name_page = next((p for p in result["pages"] if p["path"] == exp_second_path), None) + assert org_name_page + assert len(org_name_page["next"]) == 1 + + # alt_names_page = next((p for p in result["pages"] if p["path"] == "/organisation-alternative-names"), None) + # assert alt_names_page + # assert alt_names_page["next"][0]["path"] == "/organisation-address" + + # address_page = next((p for p in result["pages"] if p["path"] == "/organisation-address"), None) + # assert address_page + # assert address_page["next"][0]["path"] == "/organisation-classification" + + # assert ( + # next((p for p in result["pages"] if p["path"] == "/organisation-classification"), None)["next"][0]["path"] + # == "/summary" + # ) + assert len(org_name_page["next"]) == 1 + assert org_name_page["next"][0]["path"] == "/summary" + assert len(org_name_page["components"]) == 2 + + summary = next((p for p in result["pages"] if p["path"] == "/summary"), None) + assert summary + + +fund_id = uuid4() +round_id = uuid4() +section_id = uuid4() +form_id = uuid4() +page_1_id = uuid4() +page_2_id = uuid4() + + +@pytest.mark.seed_config( + { + "funds": [Fund(fund_id=fund_id, short_name="UTFWC", **BASIC_FUND_INFO)], + "rounds": [Round(round_id=round_id, fund_id=fund_id, short_name="UTRWC", **BASIC_ROUND_INFO)], + "sections": [ + Section( + section_id=section_id, index=1, round_id=round_id, name_in_apply_json={"en": "Organisation Information"} + ) + ], + "forms": [ + Form( + form_id=form_id, + section_id=section_id, + name_in_apply_json={"en": "About your organisation"}, + section_index=1, + runner_publish_name="about-your-org", + ) + ], + "pages": [ + Page( + page_id=page_1_id, + form_id=form_id, + display_path="organisation-name", + name_in_apply_json={"en": "Organisation Name"}, + form_index=1, + ), + Page( + page_id=page_2_id, + form_id=None, + display_path="organisation-alternative-names", + name_in_apply_json={"en": "Alternative names of your organisation"}, + form_index=2, + is_template=True, + ), + ], + "components": [ + Component( + component_id=uuid4(), + page_id=page_1_id, + title="What is your organisation's name?", + hint_text="This must match the regsitered legal organisation name", + type=ComponentType.TEXT_FIELD, + page_index=1, + theme_id=None, + options={"hideTitle": False, "classes": ""}, + runner_component_name="organisation_name", + ), + Component( + component_id=uuid4(), + page_id=page_1_id, + title="Does your organisation use any other names?", + type=ComponentType.YES_NO_FIELD, + page_index=2, + theme_id=None, + options={"hideTitle": False, "classes": ""}, + runner_component_name="does_your_organisation_use_other_names", + is_template=True, + conditions=[ + { + "name": "organisation_other_names_no", + "value": "false", # this must be lowercaes or the navigation doesn't work + "operator": "is", + "destination_page_path": "CONTINUE", + }, + { + "name": "organisation_other_names_yes", + "value": "true", # this must be lowercaes or the navigation doesn't work + "operator": "is", + "destination_page_path": "organisation-alternative-names", + }, + ], + ), + Component( + component_id=uuid4(), + page_id=page_2_id, + title="Alternative Name 1", + type=ComponentType.TEXT_FIELD, + page_index=1, + theme_id=None, + options={"hideTitle": False, "classes": ""}, + runner_component_name="alt_name_1", + is_template=True, + ), + ], + } +) +def test_build_form_json_with_conditions(seed_dynamic_data): + + f: Fund = get_fund_by_id(seed_dynamic_data["funds"][0].fund_id) + form: Form = f.rounds[0].sections[0].forms[0] + + result = build_form_json(form=form) + assert result + assert len(result["pages"]) == 4 exp_start_path = "/intro-about-your-organisation" exp_second_path = "/organisation-name" assert result["startPage"] == exp_start_path @@ -31,19 +169,12 @@ def test_build_form_json(seed_dynamic_data): org_name_page = next((p for p in result["pages"] if p["path"] == exp_second_path), None) assert org_name_page assert len(org_name_page["next"]) == 2 + assert len(org_name_page["components"]) == 2 alt_names_page = next((p for p in result["pages"] if p["path"] == "/organisation-alternative-names"), None) assert alt_names_page - assert alt_names_page["next"][0]["path"] == "/organisation-address" - - address_page = next((p for p in result["pages"] if p["path"] == "/organisation-address"), None) - assert address_page - assert address_page["next"][0]["path"] == "/organisation-classification" - - assert ( - next((p for p in result["pages"] if p["path"] == "/organisation-classification"), None)["next"][0]["path"] - == "/summary" - ) + assert alt_names_page["next"][0]["path"] == "/summary" + assert len(alt_names_page["components"]) == 1 summary = next((p for p in result["pages"] if p["path"] == "/summary"), None) assert summary @@ -51,7 +182,8 @@ def test_build_form_json(seed_dynamic_data): # TODO this fails with components from a template (branching logic) def test_build_assessment_config(seed_dynamic_data): - f: Fund = get_all_funds()[0] + + f: Fund = get_fund_by_id(seed_dynamic_data["funds"][0].fund_id) criteria = f.rounds[0].criteria[0] result = build_assessment_config(criteria_list=[criteria]) assert result @@ -59,9 +191,8 @@ def test_build_assessment_config(seed_dynamic_data): assert first_unscored assert first_unscored["name"] == "Unscored" assert len(first_unscored["subcriteria"]) == 1 - assert len(first_unscored["subcriteria"][0]["themes"]) == 2 - assert len(first_unscored["subcriteria"][0]["themes"][0]["answers"]) == 4 - assert len(first_unscored["subcriteria"][0]["themes"][1]["answers"]) == 3 + assert len(first_unscored["subcriteria"][0]["themes"]) == 1 + assert len(first_unscored["subcriteria"][0]["themes"][0]["answers"]) == 2 list_id = uuid4() diff --git a/tests/unit_test_data.py b/tests/unit_test_data.py index 3ab0aec..57219af 100644 --- a/tests/unit_test_data.py +++ b/tests/unit_test_data.py @@ -19,7 +19,7 @@ sc_1_id = uuid4() mock_s_1 = Section( section_id=section_1_id, - name_in_apply={"en": "Test Section 1"}, + name_in_apply_json={"en": "Test Section 1"}, ) mock_c_1 = Component( component_id=uuid4(), @@ -43,7 +43,7 @@ ) mock_p_1 = Page( page_id=page_1_id, - name_in_apply={"en": "A test page"}, + name_in_apply_json={"en": "A test page"}, display_path="test-display-path", components=[mock_c_1, mock_c_2], form_id=form_1_id, @@ -52,7 +52,7 @@ form_id=form_1_id, pages=[mock_p_1], section_id=section_1_id, - name_in_apply={"en": "A test form"}, + name_in_apply_json={"en": "A test form"}, runner_publish_name="a-test-form", section_index=1, ) @@ -88,7 +88,7 @@ ) mock_p_2 = Page( page_id=page_2_id, - name_in_apply={"en": "A test page 2"}, + name_in_apply_json={"en": "A test page 2"}, display_path="test-display-path-2", components=[component_with_list], form_id=None,